Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 7 additions & 4 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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);
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/lib/templateQuality.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).some((nested) => hasSetupPlaceholder(nested));
return false;
}

export function validateWorkflowTemplate(template: WorkflowTemplate & { setup?: any }) {
const errors: string[] = [];

Expand Down Expand Up @@ -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;
}

Expand Down
30 changes: 29 additions & 1 deletion apps/server/src/lib/templates.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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");
});
65 changes: 55 additions & 10 deletions apps/server/src/lib/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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: {
Expand Down Expand Up @@ -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",
Expand All @@ -272,7 +272,7 @@ const templates: WorkflowTemplate[] = [
data: {
type: "playwright_extract",
label: "Extract Table Data",
selector: "table tbody",
selector: "{{setup.table_selector}}",
saveAs: "rawRows"
}
},
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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"
}
},
Expand Down Expand Up @@ -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}}",
Expand Down Expand Up @@ -1250,6 +1250,51 @@ function withResolvedSetup(template: WorkflowTemplate): WorkflowTemplate & { set
};
}

function mergeTemplateSetupValues(setup: TemplateSetupGuide, overrides?: Record<string, unknown>) {
const merged: Record<string, unknown> = {};
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<string, unknown>): 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<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
next[key] = applySetupPlaceholders(nested, setupValues);
}
return next;
}
return value;
}

export function renderWorkflowTemplateDefinition(template: WorkflowTemplate & { setup: TemplateSetupGuide }, setupValues?: Record<string, unknown>) {
const mergedSetupValues = mergeTemplateSetupValues(template.setup, setupValues);
return applySetupPlaceholders(template.definition, mergedSetupValues) as Record<string, unknown>;
}

export function listWorkflowTemplateDefinitions() {
return templates.map((template) => withResolvedSetup(template));
}
Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Record<string, unknown>>((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]);
Expand Down Expand Up @@ -3157,7 +3175,12 @@ export default function App() {
))}
{(selectedTemplateSetup.connectionChecks || []).length ? <small>Connection checks</small> : 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 (
<small key={check.id}>
{ok ? "PASS" : "PENDING"} - {check.label}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export function getMiningSummary(days = 14) {
return request<MiningSummary>(`/api/mining/summary?${q.toString()}`);
}

export function createWorkflowFromTemplate(payload: { templateId: string; name?: string }) {
export function createWorkflowFromTemplate(payload: { templateId: string; name?: string; setupValues?: Record<string, unknown> }) {
return request<WorkflowRecord>("/api/workflows/from-template", {
method: "POST",
body: JSON.stringify(payload)
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/lib/templateSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
buildTemplateReadiness,
buildTemplateSetupInitialValues,
buildTemplateSetupProgress,
integrationExists
integrationExists,
resolveIntegrationCheckId
} from "./templateSetup";

describe("template setup helpers", () => {
Expand Down Expand Up @@ -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");
});
});
24 changes: 23 additions & 1 deletion apps/web/src/lib/templateSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ export function integrationExists(
});
}

export function resolveIntegrationCheckId(args: {
setup?: TemplateSetupGuide | null;
values?: Record<string, string>;
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<string, string>;
Expand All @@ -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)
};
});

Expand Down
Loading
Loading