import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; 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"; const aipodKind = "AipodSpec"; export function aipodSpecDirectory(): string { return process.env.AGENTRUN_AIPOD_SPEC_DIR ?? path.join(process.cwd(), "config", "aipods"); } export async function listAipodSpecs(dir = aipodSpecDirectory()): Promise { const records = await loadAipodSpecRecords(dir); return { action: "aipod-spec-list", dir, count: records.length, items: records.map((record) => summarizeAipodSpecRecord(record)), valuesPrinted: false, }; } export async function showAipodSpec(name: string, dir = aipodSpecDirectory()): Promise { const record = await getAipodSpecRecord(name, dir); return { action: "aipod-spec-show", item: summarizeAipodSpecRecord(record), spec: record.spec, valuesPrinted: false }; } export async function renderAipodSpecByName(name: string, input: RenderAipodInput = {}, dir = aipodSpecDirectory()): Promise { const record = await getAipodSpecRecord(name, dir); return renderAipodSpec(record, input); } export async function applyAipodSpec(input: unknown, dir = aipodSpecDirectory()): Promise { const spec = aipodSpecFromInput(input, "api"); await mkdir(dir, { recursive: true }); const file = path.join(dir, `${fileSafeAipodName(spec.metadata.name)}.yaml`); await writeFile(file, stringifyYaml(spec), "utf8"); const record = await loadAipodSpecFile(file); return { action: "aipod-spec-apply", mutation: true, item: summarizeAipodSpecRecord(record), valuesPrinted: false }; } export async function deleteAipodSpec(name: string, dir = aipodSpecDirectory()): Promise { const record = await getAipodSpecRecord(name, dir); await rm(record.source, { force: true }); return { action: "aipod-spec-delete", mutation: true, name: record.name, source: record.source, specHash: record.specHash, valuesPrinted: false }; } export function parseAipodSpecYaml(text: string, source = "stdin"): AipodSpec { let parsed: unknown; try { parsed = parseYaml(text); } catch (error) { throw new AgentRunError("schema-invalid", `aipod-spec YAML parse failed: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 400, details: { source, valuesPrinted: false } }); } return validateAipodSpec(parsed, source); } export function aipodSpecFromInput(input: unknown, source = "inline"): AipodSpec { const record = asRecord(input, "aipodSpecInput"); if (typeof record.yaml === "string") return parseAipodSpecYaml(record.yaml, source); if (record.spec !== undefined) return validateAipodSpec(record.spec, source); return validateAipodSpec(record, source); } export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec { const record = asRecord(input, "aipodSpec"); if (stringValue(record.apiVersion) !== aipodApiVersion) throw new AgentRunError("schema-invalid", `aipodSpec.apiVersion must be ${aipodApiVersion}`, { httpStatus: 400, details: { source } }); if (stringValue(record.kind) !== aipodKind) throw new AgentRunError("schema-invalid", `aipodSpec.kind must be ${aipodKind}`, { httpStatus: 400, details: { source } }); const metadata = asRecord(record.metadata, "aipodSpec.metadata"); const name = validateAipodName(requiredString(metadata, "name")); 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); const result: AipodSpec = { apiVersion: aipodApiVersion, kind: aipodKind, metadata: { name, ...(stringValue(metadata.displayName) ? { displayName: stringValue(metadata.displayName) as string } : {}), ...(stringValue(metadata.description) ? { description: stringValue(metadata.description) as string } : {}), ...(labels ? { labels } : {}), }, spec: { ...(stringValue(spec.tenantId) ? { tenantId: stringValue(spec.tenantId) as string } : {}), ...(stringValue(spec.projectId) ? { projectId: stringValue(spec.projectId) as string } : {}), ...(stringValue(spec.queue) ? { queue: stringValue(spec.queue) as string } : {}), ...(stringValue(spec.lane) ? { lane: stringValue(spec.lane) as string } : {}), ...(typeof spec.priority === "number" ? { priority: spec.priority } : {}), ...(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, resourceBundleRef, ...(isJsonRecord(spec.payloadDefaults) ? { payloadDefaults: spec.payloadDefaults } : {}), ...(Array.isArray(spec.references) ? { references: spec.references.map((item, index) => asRecord(item, `aipodSpec.spec.references[${index}]`)) } : {}), ...(isJsonRecord(spec.metadata) ? { metadata: spec.metadata } : {}), ...(isJsonRecord(spec.dispatchDefaults) ? { dispatchDefaults: spec.dispatchDefaults } : {}), }, }; return result; } export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput = {}): RenderedAipodQueueTask { const spec = record.spec.spec; 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); const sessionRef = renderSessionRef(spec.sessionRef ?? null, input, record, payload); const queueTask = validateCreateQueueTask({ tenantId: input.tenantId ?? spec.tenantId ?? "unidesk", projectId: input.projectId ?? spec.projectId ?? "default", queue: input.queue ?? spec.queue ?? "commander", lane: input.lane ?? spec.lane ?? "v0.1", title: input.title ?? stringValue(payload.title) ?? record.spec.metadata.displayName ?? record.name, priority: input.priority ?? spec.priority ?? 50, backendProfile: input.backendProfile ?? spec.backendProfile, providerId: input.providerId ?? spec.providerId ?? "G14", workspaceRef: input.workspaceRef ?? spec.workspaceRef ?? { kind: "opaque", path: "." }, sessionRef, executionPolicy: spec.executionPolicy, resourceBundleRef: spec.resourceBundleRef, payload, references: [...(spec.references ?? []), ...(input.references ?? [])], metadata, ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}), }) as CreateQueueTaskInput; return { action: "aipod-spec-render", aipod: summarizeAipodSpecRecord(record), queueTask, dispatchDefaults: aipodDispatchDefaults(spec.dispatchDefaults, spec.imageRef), valuesPrinted: false, }; } export function summarizeAipodSpecRecord(record: AipodSpecRecord): JsonRecord { const spec = record.spec.spec; return { name: record.name, displayName: record.spec.metadata.displayName ?? record.name, description: record.spec.metadata.description ?? null, specHash: record.specHash, 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", providerCredentials: summarizeProviderCredentials(spec.executionPolicy), toolCredentials: summarizeToolCredentials(spec.executionPolicy), resourceBundleRef: summarizeResourceBundle(spec.resourceBundleRef), dispatchDefaults: summarizeKeys(spec.dispatchDefaults), createdAt: record.createdAt, updatedAt: record.updatedAt, valuesPrinted: false, }; } async function loadAipodSpecRecords(dir: string): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { if (isNotFound(error)) return []; throw error; } const records = await Promise.all(entries .filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name)) .sort((left, right) => left.name.localeCompare(right.name)) .map((entry) => loadAipodSpecFile(path.join(dir, entry.name)))); return records.sort((left, right) => left.name.localeCompare(right.name)); } async function getAipodSpecRecord(name: string, dir: string): Promise { const lookup = normalizeLookupName(name); const records = await loadAipodSpecRecords(dir); const match = records.find((record) => normalizeLookupName(record.name) === lookup); if (!match) throw new AgentRunError("schema-invalid", `aipod-spec ${name} was not found`, { httpStatus: 404, details: { dir, available: records.map((record) => record.name), valuesPrinted: false } }); return match; } async function loadAipodSpecFile(file: string): Promise { const text = await readFile(file, "utf8"); const spec = parseAipodSpecYaml(text, file); const info = await stat(file); const updatedAt = info.mtime.toISOString(); return { name: spec.metadata.name, spec, specHash: stableHash(spec), source: file, createdAt: info.birthtime.toISOString(), updatedAt }; } function validateAipodProviderCredential(profile: BackendProfile, policy: ExecutionPolicy): void { const matching = (policy.secretScope.providerCredentials ?? []).filter((item) => item.profile === profile); if (matching.length !== 1) { throw new AgentRunError("secret-unavailable", `aipod backendProfile ${profile} requires exactly one matching provider credential SecretRef`, { httpStatus: 400, details: { profile, matchingCount: matching.length, defaultSecretName: backendProfileSpec(profile)?.defaultSecretName ?? null, valuesPrinted: false }, }); } } function renderSessionRef(base: SessionRef | null, input: RenderAipodInput, record: AipodSpecRecord, payload: JsonRecord): SessionRef | null { if (input.sessionRef !== undefined) return input.sessionRef; const sessionId = input.sessionId ?? base?.sessionId ?? defaultAipodSessionId(record, input, payload); return { ...(base ?? {}), sessionId, conversationId: base?.conversationId ?? sessionId }; } function defaultAipodSessionId(record: AipodSpecRecord, input: RenderAipodInput, payload: JsonRecord): string { const seed = input.idempotencyKey ?? `${record.name}:${stableHash({ prompt: input.prompt ?? null, payload })}`; const suffix = stableHash({ aipod: record.name, seed }).slice(0, 24); const name = fileSafeAipodName(record.name).toLowerCase().replace(/[^a-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "") || "aipod"; return `sess_${name}_${suffix}`; } function validateWorkspaceRef(record: JsonRecord): WorkspaceRef { const kind = requiredString(record, "kind"); if (!["git-worktree", "host-path", "kubernetes-pvc", "opaque"].includes(kind)) { throw new AgentRunError("schema-invalid", `workspaceRef.kind ${kind} is not supported`, { httpStatus: 400 }); } return record as WorkspaceRef; } function summarizeProviderCredentials(policy: ExecutionPolicy): JsonRecord { const items = (policy.secretScope.providerCredentials ?? []).map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], valuesPrinted: false })); return { count: items.length, profiles: items.map((item) => item.profile), items, valuesPrinted: false }; } function summarizeToolCredentials(policy: ExecutionPolicy): JsonRecord { const items = (policy.secretScope.toolCredentials ?? []).map((item) => ({ tool: item.tool, purpose: item.purpose ?? null, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], projection: item.projection, valuesPrinted: false })); return { count: items.length, tools: items.map((item) => item.tool), items, valuesPrinted: false }; } function summarizeResourceBundle(resourceBundleRef: ResourceBundleRef | null): JsonRecord | null { if (!resourceBundleRef) return null; return { kind: resourceBundleRef.kind, repoUrl: resourceBundleRef.repoUrl, ref: resourceBundleRef.ref ?? null, commitId: resourceBundleRef.commitId ?? null, bundles: { count: resourceBundleRef.bundles.length, items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, ref: item.ref ?? resourceBundleRef.ref ?? null, commitId: item.commitId ?? resourceBundleRef.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), valuesPrinted: false }, requiredSkills: { count: resourceBundleRef.requiredSkills?.length ?? 0, names: resourceBundleRef.requiredSkills?.map((item) => item.name) ?? [], valuesPrinted: false }, promptRefs: { count: resourceBundleRef.promptRefs?.length ?? 0, names: resourceBundleRef.promptRefs?.map((item) => item.name) ?? [], valuesPrinted: false }, valuesPrinted: false, }; } function summarizeKeys(value: JsonRecord | undefined): JsonRecord { return { keys: Object.keys(value ?? {}).sort(), valuesPrinted: false }; } function mergeRecords(...records: Array): 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); if (modelName) payload.model = modelName; payload.modelConfig = { ...model, valuesPrinted: false }; } function normalizeBackendProfile(value: string): BackendProfile { const profile = value.trim().toLowerCase(); if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `backendProfile ${value} must be a lowercase slug`, { httpStatus: 400 }); return profile; } function validateAipodName(value: string): string { if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(value)) throw new AgentRunError("schema-invalid", "aipodSpec.metadata.name must be an aipod name", { httpStatus: 400 }); return value; } function fileSafeAipodName(value: string): string { return value.toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "") || "aipod"; } function normalizeLookupName(value: string): string { return value.trim().toLowerCase(); } function requiredString(record: JsonRecord, key: string): string { const value = record[key]; if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${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 isJsonRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } function isNotFound(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"; }