import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { resolveTaskRoot } from "@/lib/config"; export type ArtifactKind = "image" | "step" | "source" | "manifest" | "other"; export type StudioAttachment = { id: string; taskId: string; name: string; kind: ArtifactKind; path: string; size: number; sha256: string; mime: string; }; export type TaskManifest = { schema_version: "1.0"; request: string; route: Record; source: Record; artifacts: Array<{ path: string; role: string; kind?: string }>; parameters: Record; experience: Record; assumptions: string[]; validation: string[]; viewer_links: string[]; studio?: Record; }; export function newTaskId(prefix = "cad") { return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; } export function safeTaskId(taskId: string) { const normalized = String(taskId || "").trim(); if (!/^[a-zA-Z0-9_-]{4,80}$/.test(normalized)) { throw new Error("Invalid task id"); } return normalized; } export function taskDir(taskId: string) { return path.join(resolveTaskRoot(), safeTaskId(taskId)); } function taskArtifactUrl(taskId: string, relativePath: string, version?: string | number) { const url = `/api/tasks/${encodeURIComponent(safeTaskId(taskId))}/artifacts/${relativePath .split(/[\\/]+/) .map((part) => encodeURIComponent(part)) .join("/")}`; const normalizedVersion = String(version || "").trim(); return normalizedVersion ? `${url}?v=${encodeURIComponent(normalizedVersion)}` : url; } export async function ensureTask(taskId = newTaskId()) { const id = safeTaskId(taskId); const dir = taskDir(id); await fs.mkdir(path.join(dir, "uploads"), { recursive: true }); await fs.mkdir(path.join(dir, "revisions"), { recursive: true }); return { taskId: id, dir }; } export function sanitizeFilename(filename: string) { const base = path.basename(String(filename || "upload.bin")); return base.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+/, "") || "upload.bin"; } export function classifyUpload(filename: string, mime = ""): ArtifactKind { const lowerName = filename.toLowerCase(); const lowerMime = mime.toLowerCase(); if (lowerMime.startsWith("image/")) { return "image"; } if (lowerName.endsWith(".step") || lowerName.endsWith(".stp")) { return "step"; } return "other"; } export async function sha256(buffer: Buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } export async function writeUploadedFile({ taskId, filename, mime, data, }: { taskId?: string; filename: string; mime: string; data: Buffer; }): Promise { const task = await ensureTask(taskId); const safeName = sanitizeFilename(filename); const digest = await sha256(data); const id = `file_${digest.slice(0, 16)}`; const relativePath = path.join("uploads", `${id}_${safeName}`); const absolutePath = path.join(task.dir, relativePath); await fs.writeFile(absolutePath, data); const attachment = { id, taskId: task.taskId, name: safeName, kind: classifyUpload(safeName, mime), path: relativePath, size: data.byteLength, sha256: digest, mime, }; if (attachment.kind === "step") { await upsertManifest(task.taskId, { request: `Uploaded ${safeName} for CAD reconstruction`, source: { path: relativePath, format: "step", backend: "uploaded", source_editability: "reconstruction_reference", }, artifacts: [{ path: relativePath, role: "reference", kind: "step" }], validation: [ "Uploaded STEP file is a reconstruction reference for the LLM/tool workflow.", "Uploaded STEP files are not opened directly in the right-side preview.", ], studio: { uploadedAt: new Date().toISOString(), uploadPurpose: "llm_reconstruct_cad_model", reconstructionReference: relativePath, }, }); } return attachment; } export async function manifestPath(taskId: string) { return path.join(taskDir(taskId), "cad-task.json"); } export function defaultManifest(request = ""): TaskManifest { return { schema_version: "1.0", request, route: { selected_backend: "agent-studio", runner_skill: "cad-router", project: "text-to-cad", fallback_order: ["build123d", "simplecadapi"], workflow_profiles: [], }, source: {}, artifacts: [], parameters: {}, experience: { context_kind: "agent_studio_session", methods_applied: [], }, assumptions: [], validation: [], viewer_links: [], studio: {}, }; } export async function readManifest(taskId: string): Promise { try { const source = await fs.readFile(await manifestPath(taskId), "utf8"); return JSON.parse(source) as TaskManifest; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; } throw error; } } export async function writeManifest(taskId: string, manifest: TaskManifest) { await ensureTask(taskId); await fs.writeFile(await manifestPath(taskId), `${JSON.stringify(manifest, null, 2)}\n`); return manifest; } export async function upsertManifest(taskId: string, patch: Partial) { const current = (await readManifest(taskId)) || defaultManifest(patch.request || ""); const next: TaskManifest = { ...current, ...patch, route: { ...current.route, ...(patch.route || {}) }, source: { ...current.source, ...(patch.source || {}) }, parameters: { ...current.parameters, ...(patch.parameters || {}) }, experience: { ...current.experience, ...(patch.experience || {}) }, studio: { ...(current.studio || {}), ...(patch.studio || {}) }, artifacts: patch.artifacts || current.artifacts, assumptions: patch.assumptions || current.assumptions, validation: patch.validation || current.validation, viewer_links: patch.viewer_links || current.viewer_links, }; return writeManifest(taskId, next); } function containsEditablePythonParameter(source: string, parameter: string, binding?: Record) { if ( /# CAD_AGENT_PARAMETERS_START\s*\nPARAMETERS\s*=\s*\{[\s\S]*?\}\s*\n# CAD_AGENT_PARAMETERS_END/.test(source) && new RegExp(`["']?${parameter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']?\\s*:`).test(source) ) { return true; } if ( /CAD_AGENT_PARAMETERS\s*=\s*\{/.test(source) && new RegExp(`["']${parameter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*\\{[\\s\\S]*?["']value["']\\s*:`).test(source) ) { return true; } const parameterPath = String(binding?.parameter_path || "").trim(); const candidates = Array.from(new Set([ parameter, parameterPath.split(".").filter(Boolean).at(-1) || "", parameter.toUpperCase(), parameterPath.split(".").filter(Boolean).at(-1)?.toUpperCase() || "", ].filter((candidate) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(candidate)))); return candidates.some((candidate) => ( new RegExp(`^\\s*${candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=\\s*[-+]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][-+]?\\d+)?`, "m").test(source) )); } const DIRECT_EDITABLE_BINDING_KINDS = new Set([ "python_constant", "native", "parameter", "native_python", "native_source", "source_parameter", "source_variable", "model_graph_parameter", ]); async function parameterIsDirectlyEditable(taskRoot: string, manifest: TaskManifest, parameter: Record) { if (parameter.editable !== true) return false; const binding = ( parameter.backend_binding && typeof parameter.backend_binding === "object" ? parameter.backend_binding : null ) as Record | null; const uploadReconstruction = ( manifest.studio?.uploadReconstruction && typeof manifest.studio.uploadReconstruction === "object" ? manifest.studio.uploadReconstruction : {} ) as Record; if (!binding) { return Boolean(uploadReconstruction.teacherTaskId && uploadReconstruction.teacherPath); } const backend = String(binding.backend || binding.regenerate_adapter || ""); if (backend === "surfaceir") { return Boolean(uploadReconstruction.teacherTaskId && uploadReconstruction.teacherPath); } const bindingKind = String(binding.binding_kind || ""); if (bindingKind && !DIRECT_EDITABLE_BINDING_KINDS.has(bindingKind)) { return false; } const sourcePath = String( binding.source_path || manifest.source?.native_source_path || manifest.source?.path || "", ); if (!sourcePath.endsWith(".py")) return false; const absoluteSourcePath = path.join(taskRoot, sourcePath); try { const source = await fs.readFile(absoluteSourcePath, "utf8"); return containsEditablePythonParameter(source, String(parameter.name || parameter.id || ""), binding); } catch { return false; } } async function manifestWithDirectEditableParameters(taskId: string, manifest: TaskManifest | null) { if (!manifest) return manifest; const catalog = manifest.parameters; const parameters = Array.isArray(catalog?.parameters) ? catalog.parameters.filter( (parameter): parameter is Record => parameter !== null && typeof parameter === "object", ) : []; if (!parameters.length) return manifest; const root = taskDir(taskId); const filtered = []; for (const parameter of parameters) { if (await parameterIsDirectlyEditable(root, manifest, parameter)) { filtered.push(parameter); } } return { ...manifest, parameters: { ...catalog, parameters: filtered, groups: Array.isArray((catalog as Record).groups) ? (catalog as Record).groups : [], }, }; } export async function readTask(taskId: string) { const dir = taskDir(taskId); const manifest = await manifestWithDirectEditableParameters(taskId, await readManifest(taskId)); const safeId = safeTaskId(taskId); const artifactVersion = String(manifest?.studio?.latestVersion || "").trim(); return { taskId: safeId, dir, manifest, artifacts: (manifest?.artifacts || []).map((artifact) => ({ ...artifact, url: taskArtifactUrl(safeId, artifact.path), })), latestArtifact: manifest?.studio?.latestArtifact ? { path: String(manifest.studio.latestArtifact), url: taskArtifactUrl(safeId, String(manifest.studio.latestArtifact), artifactVersion), } : null, latestPreview: manifest?.studio?.latestPreview ? { path: String(manifest.studio.latestPreview), url: taskArtifactUrl(safeId, String(manifest.studio.latestPreview), artifactVersion), } : null, latestViewerAsset: manifest?.studio?.latestViewerAsset ? { path: String(manifest.studio.latestViewerAsset), url: taskArtifactUrl(safeId, String(manifest.studio.latestViewerAsset), artifactVersion), } : null, viewerUrl: "", }; } export async function nextRevisionId(taskId: string) { const revisionsDir = path.join(taskDir(taskId), "revisions"); await fs.mkdir(revisionsDir, { recursive: true }); const entries = await fs.readdir(revisionsDir).catch(() => []); const nextNumber = entries .map((entry) => Number(/^rev_(\d+)$/.exec(entry)?.[1])) .filter(Number.isFinite) .reduce((max, value) => Math.max(max, value), 0) + 1; return `rev_${String(nextNumber).padStart(3, "0")}`; } export async function createModificationRevision({ taskId, instruction, viewerContext, }: { taskId: string; instruction: string; viewerContext: unknown[]; }) { const current = (await readManifest(taskId)) || defaultManifest(instruction); const revisionId = await nextRevisionId(taskId); const revisionDir = path.join(taskDir(taskId), "revisions", revisionId); await fs.mkdir(revisionDir, { recursive: true }); const editRequestPath = path.join("revisions", revisionId, "edit-request.json"); await fs.writeFile(path.join(taskDir(taskId), editRequestPath), `${JSON.stringify({ instruction, viewerContext, createdAt: new Date().toISOString(), }, null, 2)}\n`); const sourcePath = String(current.source?.path || ""); const editable = sourcePath.endsWith(".py") || sourcePath.endsWith(".json"); const nextManifest = await upsertManifest(taskId, { request: instruction || current.request, source: { ...current.source, source_editability: editable ? "editable_source" : "reconstructed", }, artifacts: current.artifacts, validation: [ ...current.validation, `Agent Studio captured modification ${revisionId}; generation backend must apply edit-request.json to the source of truth.`, ], studio: { ...(current.studio || {}), latestRevision: revisionId, latestEditRequest: editRequestPath, }, }); return { revisionId, manifest: nextManifest, editRequestPath }; }