Files
cadSet/cad-agent-studio/src/lib/task-store.ts
T

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 };
}