import { ActionContractError, defineAction, NoWriteError, } from "@agent-native/core/action"; import { agentEnterDocument, agentLeaveDocument, agentUpdateSelection, } from "@agent-native/core/collab"; import { accessFilter, assertAccess } from "@agent-native/core/sharing"; import { getGenerationCreativeContext, isCreativeContextOffForUser, recordGenerationCreativeContext, replaceCreativeContextElementProvenance, validateGenerationCreativeContext, } from "@agent-native/creative-context/server"; import type { CreativeContextReuseLabel } from "@agent-native/creative-context/types"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { trackDesign } from "../server/lib/design-tracking.js"; import { snapshotDesignBeforeAgentEdit } from "../server/lib/design-versions.js"; import { partitionReuseLabels } from "../server/lib/generation-reuse-labels.js"; import { readLiveSourceFile, SourceWorkspaceEditConflictError, writeInlineSourceFile, type SourceWorkspaceFile, } from "../server/source-workspace.js"; import { ApplyEditConflictError, applyOneEdit, type ApplyEditsResult, type DesignEdit, } from "../shared/apply-edits.js"; import { promoteAuthoredSvgArtworkInHtml } from "../shared/authored-svg-layers.js"; import { designOutputWriteReceipt } from "../shared/generation-output-receipt.js"; import { assertLockedLayersPreserved, LockedLayerEditError, } from "../shared/locked-layers.js"; const editBlocksSchema = z.preprocess( (v) => { if (typeof v !== "string") return v; try { return JSON.parse(v); } catch { return v; } }, z .array( z.object({ search: z .string() .min(1) .describe( "Exact text to find, with enough surrounding context to be unique", ), replace: z.string().describe("Replacement text"), }), ) .min(1), ); const reuseLabelSchema = z.object({ itemId: z.string().min(1).optional(), itemVersionId: z.string().min(1).optional(), kind: z.string().min(1), label: z.string().min(1), dataRole: z.literal("untrusted-reference").default("untrusted-reference"), elementId: z.string().min(1).optional(), influence: z .enum(["reused", "adapted", "reference-conditioned", "generated"]) .optional(), }); function stripStableNodeIdAttributes(value: string): { content: string; indexMap: number[]; } { const stableIdPattern = /\sdata-agent-native-node-id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s/>]+)/gi; let content = ""; const indexMap: number[] = []; let cursor = 0; let match: RegExpExecArray | null; while ((match = stableIdPattern.exec(value))) { const chunk = value.slice(cursor, match.index); for (let i = 0; i < chunk.length; i += 1) { content += chunk[i]; indexMap.push(cursor + i); } cursor = match.index + match[0].length; } const tail = value.slice(cursor); for (let i = 0; i < tail.length; i += 1) { content += tail[i]; indexMap.push(cursor + i); } indexMap.push(value.length); return { content, indexMap }; } function findUniqueStableIdAgnosticSpan( content: string, search: string, ): { start: number; end: number } | null { const strippedContent = stripStableNodeIdAttributes(content); const strippedSearch = stripStableNodeIdAttributes(search).content; if (!strippedSearch) return null; let count = 0; let onlyIndex = -1; let index = strippedContent.content.indexOf(strippedSearch); while (index !== -1) { count += 1; onlyIndex = index; if (count > 1) return null; index = strippedContent.content.indexOf(strippedSearch, index + 1); } if (count !== 1) return null; const lastMatched = onlyIndex + strippedSearch.length - 1; return { start: strippedContent.indexMap[onlyIndex] ?? 0, end: (strippedContent.indexMap[lastMatched] ?? content.length - 1) + 1, }; } function applyOneEditWithStableIdFallback( content: string, edit: DesignEdit, index: number, ): string { try { return applyOneEdit(content, edit, index); } catch (error) { if (!(error instanceof ApplyEditConflictError)) throw error; const span = findUniqueStableIdAgnosticSpan(content, edit.search); if (span) { return `${content.slice(0, span.start)}${edit.replace}${content.slice(span.end)}`; } throw new DesignEditSearchConflictError( error instanceof Error ? error.message : String(error), ); } } class DesignEditSearchConflictError extends Error { constructor(message: string) { super(message); this.name = "DesignEditSearchConflictError"; } } function designEditConflictOptions() { return { errorCode: "design_edit_conflict", statusCode: 409 } as const; } export function applySearchReplaceEdits( content: string, edits: DesignEdit[], ): ApplyEditsResult { let next = content; try { edits.forEach((edit, index) => { next = applyOneEditWithStableIdFallback(next, edit, index); }); } catch (error) { if (!(error instanceof DesignEditSearchConflictError)) throw error; throw new NoWriteError(error.message, designEditConflictOptions()); } return { content: next, applied: edits.length }; } export function preCheckpointNoWriteError(error: unknown): NoWriteError { if (error instanceof NoWriteError) return error; return new NoWriteError( error instanceof Error ? error.message : String(error), error instanceof ActionContractError ? { errorCode: error.errorCode, statusCode: error.statusCode } : undefined, ); } export default defineAction({ description: "Edit ONE file in a design after reading it with get-design-snapshot. " + "To move an element to another screen use move-design-element, not two " + "edits here. " + '`mode` defaults to "search-replace" (apply edit blocks); use ' + '`mode: "replace-file"` with `replacementContent` only for the ' + "broad-replacement cases below. For small localized refinements, apply " + "surgical search/replace edits — the " + "preferred way to refine an existing design without regenerating the whole " + "file (cheaper, faster, and it preserves everything you don't touch). Each " + "edit's `search` must match the current file exactly and uniquely, so " + "include enough surrounding context. Read the file first with " + "`get-design-snapshot`. Prefer restyling an existing parent over adding a " + "wrapper; add one only when no existing element can hold the layout. Keep " + "every px length a whole number. For broad copy-only " + 'changes such as translating all visible text, use `mode: "replace-file"` ' + "with `replacementContent`: the complete updated file content copied from " + "the snapshot with only the requested copy changed. After a variant pick " + "or any other selected-screen follow-up, pass the exact `fileId` from " + '`get-design-snapshot` and use `mode: "replace-file"` when replacing ' + "the representative placeholder with a complete but compact UI in the chosen " + "direction; prioritize the primary workflow and render secondary details " + "as visible controls, states, or affordances when needed. For a style change " + "(colors, fonts, spacing, dark mode), use the linked `designSystem.agentContext` " + "from `get-design-snapshot` first, then call `index-design-tokens` and reuse " + "the design's existing tokens so the edited screen matches its siblings; " + "introduce values the design does not already use only when asked. " + "When refining email HTML, preserve email-safe tables, inline-compatible styles, required HubL footer tokens and any existing internal marker; validate its handoff with export-hubspot-html, which accepts any HTML frame. To rebuild an ordinary frame as a HubSpot email, read it first and use create-file for a new sibling rather than overwriting the source. Use `generate-design` instead only for brand-new files.", schema: z .object({ designId: z.string().describe("Design project ID"), fileId: z .string() .optional() .describe( "Optional exact design file ID to edit. Use this after a variant pick or selected-screen snapshot; when provided, it wins over filename.", ), filename: z .string() .optional() .describe( "File to edit (e.g. 'index.html'). Defaults to index.html only when fileId is omitted.", ), mode: z .enum(["search-replace", "replace-file"]) .optional() .describe( "Defaults to search-replace. Use replace-file for selected variant expansion or broad copy-only edits after reading get-design-snapshot.", ), edits: editBlocksSchema .optional() .describe( "Search/replace blocks, applied in order. Use for small localized edits.", ), replacementContent: z .string() .min(1) .optional() .describe( "Complete updated file content. Use only with mode=replace-file for selected variant expansion or broad copy-only changes; preserve all HTML structure, CSS, scripts, and tweaks from get-design-snapshot. For selected variants, keep the replacement complete but compact instead of expanding secondary details into an oversized payload.", ), contextPackId: z .string() .optional() .describe("Exact Creative Context pack used for this file edit."), contextModeOverride: z .literal("off") .optional() .describe( "Disable Creative Context for this edit only without changing the saved preference.", ), reuseLabels: z .array(reuseLabelSchema) .optional() .default([]) .describe( "Exact item versions that influenced this file edit. A label without item ids that is not `generated`, or any non-generated label while Creative Context is off, is ignored and listed as `ignoredReuseLabels` in the result.", ), }) .superRefine((value, ctx) => { const mode = value.mode ?? (value.replacementContent !== undefined ? "replace-file" : "search-replace"); if (value.edits && value.replacementContent !== undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Use either edits or replacementContent in one edit-design call, not both.", path: ["replacementContent"], }); } if (mode === "search-replace" && !value.edits) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "search-replace mode requires at least one edit block in edits.", path: ["edits"], }); } if (mode === "replace-file" && value.replacementContent === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "replace-file mode requires replacementContent with the complete updated file.", path: ["replacementContent"], }); } }), mcpAnnotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false, }, // replace-file writes a fixed end state; search-replace can re-match its own // replacement text, so a replay after a lost result may apply an edit twice. retrySafe: (input) => (input.mode ?? (input.replacementContent !== undefined ? "replace-file" : "search-replace")) === "replace-file", run: async ( { designId, fileId, filename, edits, mode, replacementContent, contextPackId, contextModeOverride, reuseLabels: requestedReuseLabels, }, context, ) => { await assertAccess("design", designId, "editor"); const contextOff = requestedReuseLabels.length > 0 && (contextModeOverride === "off" || (await isCreativeContextOffForUser())); const { recordable: reuseLabels, ignored: ignoredReuseLabels } = partitionReuseLabels(requestedReuseLabels, { contextOff }); const db = getDb(); const requestedFileId = fileId?.trim(); const targetFilename = requestedFileId ? undefined : filename?.trim() || "index.html"; const targetCondition = requestedFileId ? eq(schema.designFiles.id, requestedFileId) : eq(schema.designFiles.filename, targetFilename!); const [file] = await db .select({ id: schema.designFiles.id, designId: schema.designFiles.designId, filename: schema.designFiles.filename, fileType: schema.designFiles.fileType, content: schema.designFiles.content, }) .from(schema.designFiles) .innerJoin( schema.designs, eq(schema.designFiles.designId, schema.designs.id), ) .where( and( eq(schema.designFiles.designId, designId), targetCondition, accessFilter(schema.designs, schema.designShares), ), ) .limit(1); if (!file) { throw new NoWriteError( requestedFileId ? `File id "${requestedFileId}" not found in design ${designId}` : `File "${targetFilename}" not found in design ${designId}`, { errorCode: "design_file_not_found", statusCode: 404 }, ); } const resolvedMode = mode ?? (replacementContent !== undefined ? "replace-file" : "search-replace"); const MAX_EDIT_CONFLICT_RETRIES = 2; let live: Awaited>; let base = ""; let nextContent = ""; let applied = 0; let changed = false; let creativeContext: | { contextMode: "off" | "auto" | "pinned"; contextPackId: string | null; reuseLabels: CreativeContextReuseLabel[]; elementProvenance: Array<{ elementId: string; influence: | "reused" | "adapted" | "reference-conditioned" | "generated"; itemId?: string; itemVersionId?: string; label?: string; }>; } | undefined; let checkpointed = false; for (let attempt = 0; ; attempt += 1) { const currentContent = attempt === 0 ? file.content : ( await db .select({ content: schema.designFiles.content }) .from(schema.designFiles) .where(eq(schema.designFiles.id, file.id)) .limit(1) )[0]?.content; const workspaceFile: SourceWorkspaceFile = { id: file.id, designId: file.designId, filename: file.filename ?? "", fileType: file.fileType ?? "html", content: currentContent, createdAt: null, updatedAt: null, }; live = await readLiveSourceFile(workspaceFile); base = live.content; if (resolvedMode === "replace-file") { ({ content: nextContent, applied } = { content: replacementContent ?? "", applied: 0, }); } else { try { ({ content: nextContent, applied } = applySearchReplaceEdits( base, edits ?? [], )); } catch (error) { if (!checkpointed) { throw preCheckpointNoWriteError(error); } if (error instanceof NoWriteError) { throw new ActionContractError(error.message, { errorCode: error.errorCode, statusCode: error.statusCode, }); } throw error; } } if (file.fileType === "html") { nextContent = promoteAuthoredSvgArtworkInHtml(nextContent, { previousHtml: base, }).content; } changed = nextContent !== base; creativeContext = undefined; if (!changed) break; const hasCreativeContextRequest = contextPackId !== undefined || contextModeOverride !== undefined || reuseLabels.length > 0; const previous = hasCreativeContextRequest && contextModeOverride !== "off" ? await getGenerationCreativeContext({ appId: "design", artifactType: "design", artifactId: designId, }) : null; if (hasCreativeContextRequest) { if ( contextPackId !== undefined && previous?.contextPackId && contextPackId !== previous.contextPackId ) { throw new NoWriteError( "The design edit must preserve the design's creative-context pack", { errorCode: "creative_context_pack_mismatch", statusCode: 409 }, ); } const requestedLabels: CreativeContextReuseLabel[] = reuseLabels.length ? reuseLabels : [ { kind: "design-file", label: "Net-new design edit", dataRole: "untrusted-reference", elementId: file.id, influence: "generated", }, ]; const validated = await validateGenerationCreativeContext({ contextPackId: contextPackId ?? previous?.contextPackId, contextPackSource: contextPackId === undefined ? "inherited" : "explicit", contextModeOverride, reuseLabels: requestedLabels, reuseLabelsSource: reuseLabels.length ? "explicit" : "inherited", }); const elementProvenance = validated.reuseLabels.map((label) => ({ elementId: file.id, influence: label.influence ?? ("reference-conditioned" as const), ...(label.itemId ? { itemId: label.itemId } : {}), ...(label.itemVersionId ? { itemVersionId: label.itemVersionId } : {}), label: label.label, })); const contextMode = validated.contextMode === "off" ? "off" : (previous?.contextMode ?? validated.contextMode); creativeContext = { contextMode, contextPackId: validated.contextPackId, reuseLabels: validated.reuseLabels, elementProvenance: contextMode === "off" ? elementProvenance : replaceCreativeContextElementProvenance( previous?.elementProvenance ?? [], elementProvenance, ), }; } try { assertLockedLayersPreserved(base, nextContent); } catch (error) { if (error instanceof LockedLayerEditError) { if (checkpointed) throw error; throw new NoWriteError(error.message, { errorCode: error.errorCode, statusCode: error.statusCode, }); } throw error; } if (!checkpointed) { await snapshotDesignBeforeAgentEdit(designId, context); checkpointed = true; } agentEnterDocument(file.id); agentUpdateSelection(file.id, { selection: null, editingFile: file.filename, designId, }); try { await writeInlineSourceFile({ designId: file.designId, file: workspaceFile, content: nextContent, expectedVersionHash: live.versionHash, }); } catch (error) { if ( error instanceof SourceWorkspaceEditConflictError && resolvedMode === "search-replace" && attempt < MAX_EDIT_CONFLICT_RETRIES ) { continue; } throw error; } finally { agentLeaveDocument(file.id); } if (creativeContext) { await recordGenerationCreativeContext({ appId: "design", artifactType: "design", artifactId: designId, ...creativeContext, }); } break; } trackDesign( "design_edited", { output_id: designId, output_type: "design", edit_type: resolvedMode, edits_count: applied, }, context, ); return { designId, filename: file.filename, fileId: file.id, mode: resolvedMode, editsApplied: applied, changed, _receipt: designOutputWriteReceipt(designId, changed ? 1 : 0), bytesBefore: base.length, bytesAfter: nextContent.length, ...(ignoredReuseLabels.length > 0 ? { ignoredReuseLabels } : {}), ...(creativeContext ? { contextMode: creativeContext.contextMode, contextPackId: creativeContext.contextPackId, reuseLabels: creativeContext.reuseLabels, } : {}), }; }, });