393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
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<string, unknown>;
|
|
source: Record<string, unknown>;
|
|
artifacts: Array<{ path: string; role: string; kind?: string }>;
|
|
parameters: Record<string, unknown>;
|
|
experience: Record<string, unknown>;
|
|
assumptions: string[];
|
|
validation: string[];
|
|
viewer_links: string[];
|
|
studio?: Record<string, unknown>;
|
|
};
|
|
|
|
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<StudioAttachment> {
|
|
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<TaskManifest | null> {
|
|
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<TaskManifest>) {
|
|
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<string, unknown>) {
|
|
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<string, unknown>) {
|
|
if (parameter.editable !== true) return false;
|
|
const binding = (
|
|
parameter.backend_binding && typeof parameter.backend_binding === "object"
|
|
? parameter.backend_binding
|
|
: null
|
|
) as Record<string, unknown> | null;
|
|
const uploadReconstruction = (
|
|
manifest.studio?.uploadReconstruction && typeof manifest.studio.uploadReconstruction === "object"
|
|
? manifest.studio.uploadReconstruction
|
|
: {}
|
|
) as Record<string, unknown>;
|
|
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<string, unknown> => 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<string, unknown>).groups)
|
|
? (catalog as Record<string, unknown>).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 };
|
|
}
|