318 lines
16 KiB
TypeScript
318 lines
16 KiB
TypeScript
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<JsonRecord> {
|
|
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<JsonRecord> {
|
|
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<RenderedAipodQueueTask> {
|
|
const record = await getAipodSpecRecord(name, dir);
|
|
return renderAipodSpec(record, input);
|
|
}
|
|
|
|
export async function applyAipodSpec(input: unknown, dir = aipodSpecDirectory()): Promise<JsonRecord> {
|
|
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<JsonRecord> {
|
|
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<AipodSpecRecord[]> {
|
|
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<AipodSpecRecord> {
|
|
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<AipodSpecRecord> {
|
|
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 | 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);
|
|
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";
|
|
}
|