From 00b6a60c6a40ad3b51b1e3519033d4d9728e3b4d Mon Sep 17 00:00:00 2001 From: mackeh Date: Sat, 14 Feb 2026 11:32:04 +0100 Subject: [PATCH] feat(templates): apply setup wizard values when creating workflows --- CHANGELOG.md | 1 + README.md | 2 + apps/server/src/index.ts | 11 +++-- apps/server/src/lib/templateQuality.ts | 16 ++++++- apps/server/src/lib/templates.test.ts | 30 +++++++++++- apps/server/src/lib/templates.ts | 65 ++++++++++++++++++++++---- apps/web/src/App.tsx | 29 ++++++++++-- apps/web/src/api.ts | 2 +- apps/web/src/lib/templateSetup.test.ts | 17 ++++++- apps/web/src/lib/templateSetup.ts | 24 +++++++++- docs/API_REFERENCE.md | 5 ++ docs/DEMOS.md | 1 + 12 files changed, 181 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f9419..99046d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Changed - CI now includes browser smoke validation (`Web E2E Smoke`). - CI server job now runs an explicit template quality gate (`npm run test:templates`). +- `POST /api/workflows/from-template` now applies `setupValues` into template placeholders so generated workflows use environment-specific URLs/selectors/integration IDs. - Web editor keyboard shortcuts now include undo/redo and selection-aware delete behavior. - Web recorder now follows capture -> review -> insert flow instead of immediate node injection. - Autopilot now requires explicit confirm-before-create flow and uses richer starter templates for vague prompts. diff --git a/README.md b/README.md index 697fb7e..adf3e3d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ Create from UI: 3. Complete `Template Setup Wizard` fields and click `Validate Setup`. 4. Click `Create Workflow`. +Template setup values are injected into the created workflow definition (URLs, selectors, integration IDs, thresholds, etc.), so starters are immediately environment-specific. + ## Contributor Onboarding New contributors should start here: 1. `docs/tutorials/FIRST_AUTOMATION_10_MIN.md` (fast baseline walkthrough) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b4f63c6..c439977 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -35,7 +35,7 @@ import { updateSchedule } from "./lib/scheduleStore.js"; import { buildSchedulePreview, buildUpcomingRuns, listSchedulePresets } from "./lib/schedulePreview.js"; -import { getWorkflowTemplate, listWorkflowTemplates } from "./lib/templates.js"; +import { getWorkflowTemplate, listWorkflowTemplates, renderWorkflowTemplateDefinition } from "./lib/templates.js"; import { listActivities } from "./lib/activities.js"; import { buildAutopilotPlan } from "./lib/autopilot.js"; import { understandDocument } from "./lib/documentUnderstanding.js"; @@ -850,7 +850,8 @@ app.post("/api/orchestrator/jobs/:id/sync", canManageOrchestrator, async (req, r app.post("/api/workflows/from-template", canWriteWorkflows, async (req, res) => { const schema = z.object({ templateId: z.string(), - name: z.string().optional() + name: z.string().optional(), + setupValues: z.record(z.any()).optional() }); const parsed = schema.safeParse(req.body || {}); if (!parsed.success) { @@ -863,7 +864,8 @@ app.post("/api/workflows/from-template", canWriteWorkflows, async (req, res) => return; } - const typedTemplate = asWorkflowDefinition(template.definition); + const renderedDefinition = renderWorkflowTemplateDefinition(template, parsed.data.setupValues); + const typedTemplate = asWorkflowDefinition(renderedDefinition); const workflow = await prisma.workflow.create({ data: { name: parsed.data.name?.trim() || template.name, @@ -880,7 +882,8 @@ app.post("/api/workflows/from-template", canWriteWorkflows, async (req, res) => resourceId: workflow.id, metadata: { templateId: template.id, - name: workflow.name + name: workflow.name, + setupKeys: Object.keys(parsed.data.setupValues || {}) } }); res.json(workflow); diff --git a/apps/server/src/lib/templateQuality.ts b/apps/server/src/lib/templateQuality.ts index 68fe0a9..8eab140 100644 --- a/apps/server/src/lib/templateQuality.ts +++ b/apps/server/src/lib/templateQuality.ts @@ -1,9 +1,16 @@ -import { listWorkflowTemplateDefinitions, type WorkflowTemplate } from "./templates.js"; +import { listWorkflowTemplateDefinitions, renderWorkflowTemplateDefinition, type WorkflowTemplate } from "./templates.js"; function isNonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0; } +function hasSetupPlaceholder(value: unknown): boolean { + if (typeof value === "string") return /{{\s*setup\.[a-zA-Z0-9_-]+\s*}}/.test(value); + if (Array.isArray(value)) return value.some((item) => hasSetupPlaceholder(item)); + if (value && typeof value === "object") return Object.values(value as Record).some((nested) => hasSetupPlaceholder(nested)); + return false; +} + export function validateWorkflowTemplate(template: WorkflowTemplate & { setup?: any }) { const errors: string[] = []; @@ -74,6 +81,13 @@ export function validateWorkflowTemplate(template: WorkflowTemplate & { setup?: } } + if (template.setup) { + const rendered = renderWorkflowTemplateDefinition(template as WorkflowTemplate & { setup: any }); + if (hasSetupPlaceholder(rendered)) { + errors.push("Rendered definition still contains unresolved setup placeholders"); + } + } + return errors; } diff --git a/apps/server/src/lib/templates.test.ts b/apps/server/src/lib/templates.test.ts index cfed338..5605649 100644 --- a/apps/server/src/lib/templates.test.ts +++ b/apps/server/src/lib/templates.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { getWorkflowTemplate, listWorkflowTemplates } from "./templates.js"; +import { getWorkflowTemplate, listWorkflowTemplates, renderWorkflowTemplateDefinition } from "./templates.js"; test("listWorkflowTemplates exposes curated templates", () => { const templates = listWorkflowTemplates(); @@ -29,3 +29,31 @@ test("getWorkflowTemplate returns full definition for known template", () => { assert.equal(Array.isArray((template?.definition as any).nodes), true); assert.equal(typeof (template as any)?.setup?.sampleInput, "object"); }); + +test("renderWorkflowTemplateDefinition injects setup values into definition placeholders", () => { + const template = getWorkflowTemplate("web-scrape-api-sync"); + assert.ok(template); + const definition = renderWorkflowTemplateDefinition(template!, { + source_url: "https://portal.example.internal/orders", + table_selector: "#orders-table tbody", + target_api_url: "https://api.example.internal/sync" + }); + const nodes = Array.isArray((definition as any).nodes) ? ((definition as any).nodes as any[]) : []; + const navigate = nodes.find((node) => node.id === "navigate"); + const extract = nodes.find((node) => node.id === "extract"); + const sync = nodes.find((node) => node.id === "sync"); + assert.equal(navigate?.data?.url, "https://portal.example.internal/orders"); + assert.equal(extract?.data?.selector, "#orders-table tbody"); + assert.equal(sync?.data?.url, "https://api.example.internal/sync"); +}); + +test("renderWorkflowTemplateDefinition keeps setup defaults when overrides are missing", () => { + const template = getWorkflowTemplate("invoice-intake-approval"); + assert.ok(template); + const definition = renderWorkflowTemplateDefinition(template!, {}); + const nodes = Array.isArray((definition as any).nodes) ? ((definition as any).nodes as any[]) : []; + const branch = nodes.find((node) => node.id === "branch"); + const sync = nodes.find((node) => node.id === "sync"); + assert.equal(branch?.data?.right, "10000"); + assert.equal(sync?.data?.integrationId, "finance_api"); +}); diff --git a/apps/server/src/lib/templates.ts b/apps/server/src/lib/templates.ts index 72220d7..3c05da3 100644 --- a/apps/server/src/lib/templates.ts +++ b/apps/server/src/lib/templates.ts @@ -164,10 +164,10 @@ const templates: WorkflowTemplate[] = [ position: { x: 1580, y: 80 }, data: { type: "conditional_branch", - label: "Amount > 10000?", + label: "Amount > {{setup.approval_threshold}}?", inputKey: "invoiceAmount", operator: "gt", - right: 10000, + right: "{{setup.approval_threshold}}", trueTarget: "approval", falseTarget: "sync" } @@ -189,7 +189,7 @@ const templates: WorkflowTemplate[] = [ data: { type: "integration_request", label: "Sync to Finance System", - integrationId: "finance_api", + integrationId: "{{setup.finance_api}}", method: "POST", path: "/invoices", body: { @@ -263,7 +263,7 @@ const templates: WorkflowTemplate[] = [ id: "navigate", type: "action", position: { x: 330, y: 80 }, - data: { type: "playwright_navigate", label: "Open Source Portal", url: "https://example.com/reports/orders" } + data: { type: "playwright_navigate", label: "Open Source Portal", url: "{{setup.source_url}}" } }, { id: "extract", @@ -272,7 +272,7 @@ const templates: WorkflowTemplate[] = [ data: { type: "playwright_extract", label: "Extract Table Data", - selector: "table tbody", + selector: "{{setup.table_selector}}", saveAs: "rawRows" } }, @@ -310,7 +310,7 @@ const templates: WorkflowTemplate[] = [ type: "http_request", label: "POST Rows", method: "POST", - url: "https://example.com/api/order-sync", + url: "{{setup.target_api_url}}", body: { rows: "{{cleanRows}}" }, saveAs: "syncResult" } @@ -409,7 +409,7 @@ const templates: WorkflowTemplate[] = [ type: "http_request", label: "Send to Import API", method: "POST", - url: "https://example.com/api/csv-import", + url: "{{setup.import_api_url}}", body: { rows: "{{normalizedRows}}" }, saveAs: "importResult" } @@ -534,7 +534,7 @@ const templates: WorkflowTemplate[] = [ data: { type: "integration_request", label: "Create Ticket", - integrationId: "helpdesk_api", + integrationId: "{{setup.helpdesk_api}}", method: "POST", path: "/tickets", body: { @@ -604,7 +604,7 @@ const templates: WorkflowTemplate[] = [ type: "http_request", label: "Call Health Endpoint", method: "GET", - url: "https://example.com/health", + url: "{{setup.health_url}}", saveAs: "healthPayload" } }, @@ -643,7 +643,7 @@ const templates: WorkflowTemplate[] = [ type: "http_request", label: "Send Alert", method: "POST", - url: "https://example.com/webhooks/alerts", + url: "{{setup.alert_webhook}}", body: { service: "forgeflow", status: "{{healthState}}", @@ -1250,6 +1250,51 @@ function withResolvedSetup(template: WorkflowTemplate): WorkflowTemplate & { set }; } +function mergeTemplateSetupValues(setup: TemplateSetupGuide, overrides?: Record) { + const merged: Record = {}; + for (const field of setup.requiredInputs || []) { + if (field.defaultValue !== undefined) { + merged[field.id] = field.defaultValue; + } + } + for (const [key, value] of Object.entries(overrides || {})) { + if (value === undefined || value === null) continue; + if (typeof value === "string" && value.trim().length === 0) continue; + merged[key] = value; + } + return merged; +} + +function applySetupPlaceholders(value: unknown, setupValues: Record): unknown { + if (typeof value === "string") { + const fullMatch = value.match(/^{{\s*setup\.([a-zA-Z0-9_-]+)\s*}}$/); + if (fullMatch?.[1]) { + const resolved = setupValues[fullMatch[1]]; + return resolved !== undefined ? resolved : value; + } + return value.replace(/{{\s*setup\.([a-zA-Z0-9_-]+)\s*}}/g, (_all, key: string) => { + const resolved = setupValues[key]; + return resolved === undefined ? "" : String(resolved); + }); + } + if (Array.isArray(value)) { + return value.map((item) => applySetupPlaceholders(item, setupValues)); + } + if (value && typeof value === "object") { + const next: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + next[key] = applySetupPlaceholders(nested, setupValues); + } + return next; + } + return value; +} + +export function renderWorkflowTemplateDefinition(template: WorkflowTemplate & { setup: TemplateSetupGuide }, setupValues?: Record) { + const mergedSetupValues = mergeTemplateSetupValues(template.setup, setupValues); + return applySetupPlaceholders(template.definition, mergedSetupValues) as Record; +} + export function listWorkflowTemplateDefinitions() { return templates.map((template) => withResolvedSetup(template)); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 81b388f..a414196 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -99,7 +99,8 @@ import { STARTER_WALKTHROUGH_STEPS, clampWalkthroughIndex } from "./lib/starterW import { buildTemplateReadiness, buildTemplateSetupInitialValues, - integrationExists + integrationExists, + resolveIntegrationCheckId } from "./lib/templateSetup"; import { buildPersistedDefinition, hashDefinition } from "./lib/workflowDraft"; import type { @@ -1391,9 +1392,26 @@ export default function App() { setFeedback("Template setup checks are not ready yet (run preflight/check integrations)", "error"); return; } + const setupValues = Object.entries(templateSetupValues).reduce>((acc, [key, value]) => { + const trimmed = String(value || "").trim(); + if (!trimmed) return acc; + const field = selectedTemplateSetup?.requiredInputs?.find((item) => item.id === key); + if (field?.kind === "json") { + try { + acc[key] = JSON.parse(trimmed); + return acc; + } catch { + acc[key] = trimmed; + return acc; + } + } + acc[key] = trimmed; + return acc; + }, {}); const created = await createWorkflowFromTemplate({ templateId: selectedTemplateId, - name: templateWorkflowName.trim() || undefined + name: templateWorkflowName.trim() || undefined, + setupValues }); setTemplateWorkflowName(""); setWorkflowList((list) => [created, ...list]); @@ -3157,7 +3175,12 @@ export default function App() { ))} {(selectedTemplateSetup.connectionChecks || []).length ? Connection checks : null} {(selectedTemplateSetup.connectionChecks || []).map((check) => { - const ok = check.type === "preflight" ? templatePreflight.status === "pass" : integrationExists(check.integrationId, integrations); + const integrationTarget = resolveIntegrationCheckId({ + setup: selectedTemplateSetup, + values: templateSetupValues, + integrationId: check.integrationId + }); + const ok = check.type === "preflight" ? templatePreflight.status === "pass" : integrationExists(integrationTarget, integrations); return ( {ok ? "PASS" : "PENDING"} - {check.label} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c7d48dc..caf74b9 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -234,7 +234,7 @@ export function getMiningSummary(days = 14) { return request(`/api/mining/summary?${q.toString()}`); } -export function createWorkflowFromTemplate(payload: { templateId: string; name?: string }) { +export function createWorkflowFromTemplate(payload: { templateId: string; name?: string; setupValues?: Record }) { return request("/api/workflows/from-template", { method: "POST", body: JSON.stringify(payload) diff --git a/apps/web/src/lib/templateSetup.test.ts b/apps/web/src/lib/templateSetup.test.ts index a9a4f79..8dcfec2 100644 --- a/apps/web/src/lib/templateSetup.test.ts +++ b/apps/web/src/lib/templateSetup.test.ts @@ -3,7 +3,8 @@ import { buildTemplateReadiness, buildTemplateSetupInitialValues, buildTemplateSetupProgress, - integrationExists + integrationExists, + resolveIntegrationCheckId } from "./templateSetup"; describe("template setup helpers", () => { @@ -49,4 +50,18 @@ describe("template setup helpers", () => { expect(readiness.ready).toBe(true); expect(readiness.checksReady).toBe(true); }); + + test("resolveIntegrationCheckId uses integration field override", () => { + const mapped = resolveIntegrationCheckId({ + setup: { + ...setup, + requiredInputs: [ + { id: "finance_api", label: "Finance API", kind: "integration", required: true, defaultValue: "finance_api" } + ] + }, + values: { finance_api: "finance_prod" }, + integrationId: "finance_api" + }); + expect(mapped).toBe("finance_prod"); + }); }); diff --git a/apps/web/src/lib/templateSetup.ts b/apps/web/src/lib/templateSetup.ts index 3d55105..c4506b8 100644 --- a/apps/web/src/lib/templateSetup.ts +++ b/apps/web/src/lib/templateSetup.ts @@ -35,6 +35,23 @@ export function integrationExists( }); } +export function resolveIntegrationCheckId(args: { + setup?: TemplateSetupGuide | null; + values?: Record; + integrationId?: string; +}) { + const integrationId = String(args.integrationId || "").trim(); + if (!integrationId) return ""; + const setup = args.setup; + const values = args.values || {}; + const mappedField = (setup?.requiredInputs || []).find( + (field) => field.kind === "integration" && (field.defaultValue === integrationId || field.id === integrationId) + ); + if (!mappedField) return integrationId; + const override = String(values[mappedField.id] || "").trim(); + return override || mappedField.defaultValue || integrationId; +} + export function buildTemplateReadiness(args: { setup?: TemplateSetupGuide | null; values?: Record; @@ -50,9 +67,14 @@ export function buildTemplateReadiness(args: { if (check.type === "preflight") { return { id: check.id, ok: preflightReady }; } + const integrationTarget = resolveIntegrationCheckId({ + setup, + values, + integrationId: check.integrationId + }); return { id: check.id, - ok: integrationExists(check.integrationId, args.integrations) + ok: integrationExists(integrationTarget, args.integrations) }; }); diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 8d613f6..5c70a5d 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -73,6 +73,11 @@ Activity catalog response also includes: - `roadmap[]`: ordered pack rollout view (`id`, `label`, `phase`, availability) - `phaseFocus`: current/next/later implementation focus +`POST /api/workflows/from-template` payload supports optional setup injection: +- `templateId` (required) +- `name` (optional) +- `setupValues` (optional object): values used to replace `{{setup.}}` placeholders in the template definition before workflow creation. + ## 6. Document Intelligence and Orchestrator | Method | Path | Permission | Description | diff --git a/docs/DEMOS.md b/docs/DEMOS.md index 264216a..30141be 100644 --- a/docs/DEMOS.md +++ b/docs/DEMOS.md @@ -103,6 +103,7 @@ Goal: Show first-time user guidance from template selection to run readiness. 3. Click `Validate Setup` and confirm checks show `PASS`. 4. Click `Copy Sample Input` and inspect JSON payload. 5. Start `Starter Walkthrough` and progress through all steps. +6. Create workflow and inspect key nodes to confirm setup values were injected. Expected outcome: - Setup friction is reduced with explicit required fields/checks.