feat: add aipod spec Artificer assembly
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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 { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validateResourceBundleRef, validateSessionRef } from "./validation.js";
|
||||
|
||||
declare const Bun: { YAML: { parse(text: string): unknown; stringify(value: unknown): string } };
|
||||
|
||||
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, Bun.YAML.stringify(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 = Bun.YAML.parse(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 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 } : {}),
|
||||
...(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 metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash });
|
||||
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);
|
||||
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: spec.dispatchDefaults ?? {},
|
||||
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,
|
||||
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): SessionRef | null {
|
||||
if (input.sessionRef !== undefined) return input.sessionRef;
|
||||
if (!input.sessionId) return base;
|
||||
return { ...(base ?? {}), sessionId: input.sessionId };
|
||||
}
|
||||
|
||||
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,
|
||||
gitMirror: resourceBundleRef.gitMirror ? { enabled: resourceBundleRef.gitMirror.enabled ?? true, baseUrl: resourceBundleRef.gitMirror.baseUrl ?? null, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false },
|
||||
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 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";
|
||||
}
|
||||
Reference in New Issue
Block a user