Skip to content

feat: workflow automation templates and integration-linked automations#805

Merged
Israeltheminer merged 6 commits into
mainfrom
feat/workflow-automation-templates
Mar 17, 2026
Merged

feat: workflow automation templates and integration-linked automations#805
Israeltheminer merged 6 commits into
mainfrom
feat/workflow-automation-templates

Conversation

@Israeltheminer
Copy link
Copy Markdown
Collaborator

@Israeltheminer Israeltheminer commented Mar 17, 2026

Summary

  • Add workflow template registry with 11 pre-built templates (Gmail, Outlook, Shopify, Circuly, OneDrive, etc.)
  • New WorkflowTemplateGrid component with integration-based filtering
  • Enhanced CreateAutomationDialog with "Blank" and "From template" tabs
  • IntegrationRelatedAutomations section in integration manage dialog shows linked automations with "Create automation" button
  • find_related_automations query to discover automations using a specific integration
  • Gmail connector get_thread fix: use format=email for proper message normalization
  • Outlook template fix: remove invalid format=email from list_messages operations
  • Vite dev server plugin to serve workflow templates from local filesystem during development
  • Extracted normalize_email helper with comprehensive test coverage

Test plan

  • Open integration manage dialog → verify "Automations" section with "Create automation" button
  • Click "Create automation" → verify template grid shows filtered templates for that integration
  • Select a template → verify automation is created with pre-populated steps, navigates to editor
  • Open "Create Automation" from automations page → verify blank + template tabs both work
  • Run Gmail email-sync template end-to-end → verify emails sync into conversations
  • Run bun run --filter @tale/platform test

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Tabbed Create Automation dialog with template browser and integration filtering
    • Workflow templates for Gmail, Outlook, Shopify, OneDrive, and Circuly integrations
    • Related automations section in integration settings
    • OAuth2 success/error notifications
  • Improvements

    • Enhanced email format handling across integrations
    • Improved workflow variable resolution

…omation creation

Remove automatic sync workflow provisioning when integrations connect. Instead,
users create automations from pre-built templates via a new UI.

Backend changes:
- Remove syncWorkflowId provisioning from oauth2_token_exchange and test_connection
- Remove sync_workflow_id redirect param from oauth2_callback
- Add find_related_automations query to show automations linked to an integration
- Extract normalize_email helper for resilient email parsing in workflow engine
- Fix Gmail get_thread to use format=email for proper message mapping
- Fix Outlook template removing invalid format=email from list_messages
- Add Vite plugin to serve workflow templates locally in dev mode

Frontend changes:
- Add workflow template registry with 11 pre-built templates (Gmail, Outlook, Shopify, etc.)
- Add WorkflowTemplateGrid component with filtering by integration
- Enhance CreateAutomationDialog with "Blank" and "From template" tabs
- Add IntegrationRelatedAutomations section showing linked automations
- Remove sync_workflow_id navigation from integration settings route
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

This PR introduces workflow template-based automation creation and enhances email normalization across integrations. It adds a tabbed Create Automation dialog with template browsing, a WorkflowTemplateGrid component, backend queries to find automations related to integrations, and email normalization utilities that convert raw Gmail API responses into a unified EmailType structure. The changes include workflow JSON updates for parameter reference resolution, a Vite plugin for serving local templates, OAuth2 result handling in the integration route, and helper functions for email parsing and attachment extraction.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding workflow automation templates and integration-linked automations features.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/workflow-automation-templates
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can use Trivy to scan for security misconfigurations and secrets in Infrastructure as Code files.

Add a .trivyignore file to your project to customize which findings Trivy reports.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/platform/app/features/automations/components/automation-create-dialog.tsx (1)

74-77: ⚠️ Potential issue | 🟠 Major

Block tab changes while a create request is in flight.

The busy flags live inside each tab, but Tabs stays interactive at the dialog level. A user can submit on one tab, switch before the mutation resolves, and start a second automation create flow. Lift a shared busy flag to CreateAutomationDialogContent and ignore onValueChange while it is set.

Also applies to: 168-168, 244-248, 284-289

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/automations/components/automation-create-dialog.tsx`
around lines 74 - 77, The Tabs component is still interactive while a mutation
is in flight because each tab keeps its own busy flag; lift a shared busy flag
(e.g., a new state like isSaving) up into CreateAutomationDialogContent and set
it whenever any create mutation starts/finishes (use the existing
formState.isSubmitting or mutation status to drive it). Pass that shared flag
down to child tabs and modify the Tabs onValueChange handler to
early-return/ignore changes while isSaving is true (prevent tab switching and
new submissions). Update any checks that previously used per-tab busy flags
(references around the form submission and the Tabs onValueChange) to use the
shared isSaving instead so the dialog becomes non-interactive until the create
completes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/integrations/gmail/connector.ts`:
- Around line 521-535: When params.format === 'email' the code returns the
result of mapGmailToEmailType (using getGmailAccountEmail and base64Decode) but
drops any downloaded files stored on message.attachmentFiles (populated when
includeAttachments is true); update the return logic in the params.format ===
'email' branch to preserve attachmentFiles by merging message.attachmentFiles
into the mapped object or returning a wrapper that includes both the mapped
email and attachmentFiles so downloaded files remain accessible in the response.

In
`@services/platform/app/features/automations/components/workflow-template-grid.tsx`:
- Around line 18-20: The component is calling the async onTemplateSelected
handler fire-and-forget, so clear fetchingTemplate too early; update the
selection flow (the handler named like selectTemplate or handleTemplateSelect
and the fetchingTemplate state setter) to await the result of
props.onTemplateSelected(data) before resetting fetchingTemplate and re-enabling
UI, i.e. set fetchingTemplate true, await props.onTemplateSelected(...), then in
finally set fetchingTemplate false; apply the same change to any other
click/selection paths in this file that invoke onTemplateSelected so
interactions remain disabled until the async createAutomation completes.

In
`@services/platform/app/features/automations/utils/__tests__/fetch-workflow-template.test.ts`:
- Around line 108-120: The test for invalid JSON currently mocks fetch to reject
via json(), but fetchWorkflowTemplate reads response.text() and then parses
JSON; update the mock in the 'returns error for invalid JSON' test so the mocked
Response includes a text() method that returns a Promise resolving to a non-JSON
string (e.g. 'not json'), keep ok: true and status: 200, then assert
fetchWorkflowTemplate(template) returns success=false and error contains
'Invalid template format'; target the fetch mock in this test and the
fetchWorkflowTemplate function to ensure the parse path is exercised.
- Around line 41-47: The test helper mockResponse currently provides a json()
method but fetchWorkflowTemplate calls response.text() then JSON.parse(), so
update mockResponse to implement text(): () => Promise.resolve(body) (and keep
ok/status values) so fetchWorkflowTemplate receives the raw JSON string;
reference the mockResponse helper and the fetchWorkflowTemplate function to
locate and change the mocked Response shape accordingly.

In `@services/platform/app/features/automations/utils/fetch-workflow-template.ts`:
- Around line 52-56: Add a timeout using AbortController around the fetch(url)
call in fetch-workflow-template.ts so the request cannot hang indefinitely:
create an AbortController, pass its signal to fetch(url, { signal }), start a
setTimeout that calls controller.abort() after a configurable timeout (e.g.,
5s), and clear the timeout once fetch resolves; in the catch block distinguish
an abort error vs other network errors and return an appropriate error message
(e.g., "Request timed out while fetching template" for abort, otherwise the
existing network error) while keeping the existing response handling intact.
- Around line 65-71: The implementation in fetch-workflow-template.ts currently
reads the response via response.text() and JSON.parse, which breaks tests that
only mock response.json(); modify the parsing to use await response.json()
(e.g., replace the response.text() + JSON.parse block that sets the local
variable data with a try/catch around await response.json()), keep the same
error branch (returning { success: false, error: 'Invalid template format' }) on
parse failure, and update any local references to the variable data accordingly
so fetchWorkflowTemplate (or the function in this file) consumes the parsed
object returned by response.json().

In
`@services/platform/app/features/settings/integrations/components/integration-details.tsx`:
- Around line 430-433: IntegrationDetails currently returns early based on
hasAnyDetails but you added <IntegrationRelatedAutomations .../> after that
guard so the automations panel never shows; update the guard to include related
automations or remove the early return. Specifically, in the IntegrationDetails
component adjust the hasAnyDetails calculation to incorporate the
related-automations condition (e.g., a new hasRelatedAutomations flag derived
from the related automations data / length) or drop the early return that checks
hasAnyDetails so IntegrationRelatedAutomations can render; ensure you reference
the IntegrationRelatedAutomations usage and the hasAnyDetails/early-return logic
so the guard matches what the component now renders.

In
`@services/platform/app/features/settings/integrations/components/integration-manage/integration-related-automations.tsx`:
- Around line 41-66: Translate the hardcoded loading strings by routing them
through the i18n translator instead of plain English: replace the
aria-label="Loading automations" with
aria-label={t('settings.integrations.loadingAutomations')} and change each
Skeleton label prop (e.g., label="Loading automations count", label="Loading
automation") to label={t('settings.integrations.loadingAutomationsCount')},
label={t('settings.integrations.loadingAutomationSmall')} (or similar keys).
Ensure the component imports/uses the t function (e.g., from useTranslation) at
the top of integration-related-automations.tsx and update the translation
resource keys accordingly so screen-reader labels and Skeleton labels are
localized.

In `@services/platform/convex/integrations/find_related_automations.ts`:
- Around line 81-129: The code currently marks a root as active whenever any
sibling wfDefinitions with status 'active' exists (activeVersion), even if that
active version does not reference the integration; instead ensure the chosen
activeVersion is one of the matched versions that reference the integration.
Change the logic in the map over [...rootDocs.values()] to first compute the
set/list of matched version IDs for this root (the versions returned by the
integration-matching query), then when searching for an activeVersion via
ctx.db.query('wfDefinitions').withIndex('by_root_status', ...) only accept an
activeVersion whose _id is in that matched set; similarly, when deciding
hasArchived or draft, base the determination on whether any matched version has
status 'archived' or 'draft' (or fall back to checking the root document only if
no matched versions exist). Update the values pushed into results (status and
activeVersionId) to reflect the matched-version-aware selection.

In
`@services/platform/convex/workflow_engine/action_defs/conversation/helpers/normalize_email.ts`:
- Around line 107-118: The decodeBase64Url function currently uses atob() which
yields a binary string and corrupts non-ASCII UTF-8 content; update
decodeBase64Url to convert the base64url string to standard base64, decode to
raw bytes, then decode those bytes to a UTF-8 string using TextDecoder (or
equivalent) instead of returning the atob result directly; in other words, in
decodeBase64Url replace the try { return atob(base64); } catch { return ''; }
logic with code that calls atob(base64), builds a Uint8Array from charCodeAt for
each character, and feeds that array into new TextDecoder('utf-8').decode(...)
and return the result, still returning '' on errors so existing callers of
decodeBase64Url keep their semantics.
- Around line 187-202: Add a short JSDoc above normalizeEmail (and mirror for
normalizeEmails) clarifying that the function accepts either a raw Gmail message
(detected by isRawGmailMessage) or a pre-mapped EmailType and that non-Gmail
inputs are assumed to already conform to EmailType (i.e., callers are
responsible for providing valid EmailType data); reference normalizeEmail,
normalizeEmails, EmailType, isRawGmailMessage, and gmailToEmailType in the
comment so future maintainers understand the cast behavior and caller
responsibility.

In `@services/platform/vite.config.ts`:
- Around line 24-31: The middleware attached via server.middlewares.use that
builds filePath with path.join(workflowTemplatesDir, req.url) is vulnerable to
path traversal; fix it by resolving and validating the requested path before
reading files: normalize/strip query from req.url, compute const resolved =
path.resolve(workflowTemplatesDir, sanitizedReqUrl) and ensure
resolved.startsWith(path.resolve(workflowTemplatesDir) + path.sep) (or use
path.relative to ensure it does not escape), and only then call
fs.existsSync/fs.statSync and fs.createReadStream; if validation fails, call
next() to avoid serving files outside the workflowTemplatesDir.

---

Outside diff comments:
In
`@services/platform/app/features/automations/components/automation-create-dialog.tsx`:
- Around line 74-77: The Tabs component is still interactive while a mutation is
in flight because each tab keeps its own busy flag; lift a shared busy flag
(e.g., a new state like isSaving) up into CreateAutomationDialogContent and set
it whenever any create mutation starts/finishes (use the existing
formState.isSubmitting or mutation status to drive it). Pass that shared flag
down to child tabs and modify the Tabs onValueChange handler to
early-return/ignore changes while isSaving is true (prevent tab switching and
new submissions). Update any checks that previously used per-tab busy flags
(references around the form submission and the Tabs onValueChange) to use the
shared isSaving instead so the dialog becomes non-interactive until the create
completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: de45cab3-9a3a-4655-874b-0de2c544a734

📥 Commits

Reviewing files that changed from the base of the PR and between 64c2712 and 2d2cf94.

⛔ Files ignored due to path filters (1)
  • services/platform/convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (20)
  • examples/integrations/gmail/connector.ts
  • examples/workflows/gmail/email-sync.json
  • examples/workflows/outlook/email-sync.json
  • services/platform/app/features/automations/components/automation-create-dialog.tsx
  • services/platform/app/features/automations/components/workflow-template-grid.tsx
  • services/platform/app/features/automations/constants/workflow-templates.ts
  • services/platform/app/features/automations/utils/__tests__/fetch-workflow-template.test.ts
  • services/platform/app/features/automations/utils/fetch-workflow-template.ts
  • services/platform/app/features/settings/integrations/components/integration-details.tsx
  • services/platform/app/features/settings/integrations/components/integration-manage/integration-related-automations.tsx
  • services/platform/app/features/settings/integrations/hooks/use-integration-manage.ts
  • services/platform/app/routes/dashboard/$id/settings/integrations.tsx
  • services/platform/convex/integrations/__tests__/find_related_automations.test.ts
  • services/platform/convex/integrations/find_related_automations.ts
  • services/platform/convex/integrations/oauth2_token_exchange.ts
  • services/platform/convex/integrations/queries.ts
  • services/platform/convex/workflow_engine/action_defs/conversation/helpers/__tests__/normalize_email.test.ts
  • services/platform/convex/workflow_engine/action_defs/conversation/helpers/create_conversation_from_email.ts
  • services/platform/convex/workflow_engine/action_defs/conversation/helpers/normalize_email.ts
  • services/platform/vite.config.ts

Comment on lines +521 to +535
if (params.format === 'email') {
const accountEmail = getGmailAccountEmail(http, headers);
const mapped = mapGmailToEmailType(
message as unknown as GmailMessage,
accountEmail,
base64Decode,
);
return {
success: true,
operation: 'get_message',
data: mapped,
count: 1,
timestamp: Date.now(),
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Downloaded attachment files are lost when format='email' is combined with includeAttachments=true.

When includeAttachments=true, lines 494-519 download attachments and store them in message.attachmentFiles. However, when format='email', this block returns a mapped result that excludes attachmentFiles. The downloaded files become inaccessible in the response.

If this combination is a valid use case, consider including the attachment files in the mapped response:

Proposed fix
   if (params.format === 'email') {
     const accountEmail = getGmailAccountEmail(http, headers);
     const mapped = mapGmailToEmailType(
       message as unknown as GmailMessage,
       accountEmail,
       base64Decode,
     );
+    // Carry over downloaded attachment files if present
+    if (message.attachmentFiles) {
+      (mapped as Record<string, unknown>).attachmentFiles = message.attachmentFiles;
+    }
     return {
       success: true,
       operation: 'get_message',
       data: mapped,
       count: 1,
       timestamp: Date.now(),
     };
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (params.format === 'email') {
const accountEmail = getGmailAccountEmail(http, headers);
const mapped = mapGmailToEmailType(
message as unknown as GmailMessage,
accountEmail,
base64Decode,
);
return {
success: true,
operation: 'get_message',
data: mapped,
count: 1,
timestamp: Date.now(),
};
}
if (params.format === 'email') {
const accountEmail = getGmailAccountEmail(http, headers);
const mapped = mapGmailToEmailType(
message as unknown as GmailMessage,
accountEmail,
base64Decode,
);
// Carry over downloaded attachment files if present
if (message.attachmentFiles) {
(mapped as Record<string, unknown>).attachmentFiles = message.attachmentFiles;
}
return {
success: true,
operation: 'get_message',
data: mapped,
count: 1,
timestamp: Date.now(),
};
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/integrations/gmail/connector.ts` around lines 521 - 535, When
params.format === 'email' the code returns the result of mapGmailToEmailType
(using getGmailAccountEmail and base64Decode) but drops any downloaded files
stored on message.attachmentFiles (populated when includeAttachments is true);
update the return logic in the params.format === 'email' branch to preserve
attachmentFiles by merging message.attachmentFiles into the mapped object or
returning a wrapper that includes both the mapped email and attachmentFiles so
downloaded files remain accessible in the response.

Comment on lines +18 to +20
interface WorkflowTemplateGridProps {
integrationName?: string;
onTemplateSelected: (data: WorkflowTemplateData) => void;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Await onTemplateSelected before releasing the grid.

TemplateTabContent passes an async selection handler, but this component treats it as fire-and-forget. fetchingTemplate is cleared in finally immediately after invoking the callback, so the buttons/cancel path re-enable while createAutomation is still running. That allows a second selection or a dialog close while an automation is being created in the background.

Suggested fix
 interface WorkflowTemplateGridProps {
   integrationName?: string;
-  onTemplateSelected: (data: WorkflowTemplateData) => void;
+  onTemplateSelected: (
+    data: WorkflowTemplateData,
+  ) => void | Promise<void>;
 }
@@
       try {
         const result = await fetchWorkflowTemplate(template);
         if (result.success && result.data) {
-          onTemplateSelected(result.data);
+          await onTemplateSelected(result.data);
         } else {
           setError(result.error ?? t('templates.fetchError'));
         }

Also applies to: 39-57

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/automations/components/workflow-template-grid.tsx`
around lines 18 - 20, The component is calling the async onTemplateSelected
handler fire-and-forget, so clear fetchingTemplate too early; update the
selection flow (the handler named like selectTemplate or handleTemplateSelect
and the fetchingTemplate state setter) to await the result of
props.onTemplateSelected(data) before resetting fetchingTemplate and re-enabling
UI, i.e. set fetchingTemplate true, await props.onTemplateSelected(...), then in
finally set fetchingTemplate false; apply the same change to any other
click/selection paths in this file that invoke onTemplateSelected so
interactions remain disabled until the async createAutomation completes.

Comment on lines +41 to +47
function mockResponse(body: string, ok = true) {
return Promise.resolve({
ok,
status: ok ? 200 : 404,
json: () => Promise.resolve(JSON.parse(body)),
} as Response);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Test mock provides json() but implementation uses text() — root cause of all test failures.

The mockResponse helper mocks response.json(), but fetchWorkflowTemplate calls response.text() followed by JSON.parse(). This causes:

  1. "fetches and parses a workflow template" — fails because text() returns undefined
  2. "caches successful results" — fails for the same reason
  3. "returns error for missing workflowConfig/stepsConfig" — fails with "Invalid template format" instead of "Invalid template structure" because JSON.parse(undefined) throws before validation runs
🐛 Proposed fix for mockResponse
 function mockResponse(body: string, ok = true) {
   return Promise.resolve({
     ok,
     status: ok ? 200 : 404,
-    json: () => Promise.resolve(JSON.parse(body)),
+    text: () => Promise.resolve(body),
   } as Response);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function mockResponse(body: string, ok = true) {
return Promise.resolve({
ok,
status: ok ? 200 : 404,
json: () => Promise.resolve(JSON.parse(body)),
} as Response);
}
function mockResponse(body: string, ok = true) {
return Promise.resolve({
ok,
status: ok ? 200 : 404,
text: () => Promise.resolve(body),
} as Response);
}
Suggested change
function mockResponse(body: string, ok = true) {
return Promise.resolve({
ok,
status: ok ? 200 : 404,
json: () => Promise.resolve(JSON.parse(body)),
} as Response);
}
function mockResponse(body: string, ok = true) {
return Promise.resolve({
ok,
status: ok ? 200 : 404,
text: () => Promise.resolve(body),
} satisfies Response);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/automations/utils/__tests__/fetch-workflow-template.test.ts`
around lines 41 - 47, The test helper mockResponse currently provides a json()
method but fetchWorkflowTemplate calls response.text() then JSON.parse(), so
update mockResponse to implement text(): () => Promise.resolve(body) (and keep
ok/status values) so fetchWorkflowTemplate receives the raw JSON string;
reference the mockResponse helper and the fetchWorkflowTemplate function to
locate and change the mocked Response shape accordingly.

Comment on lines +108 to +120
it('returns error for invalid JSON', async () => {
vi.spyOn(globalThis, 'fetch').mockReturnValue(
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('Unexpected token')),
} as Response),
);

const result = await fetchWorkflowTemplate(template);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid template format');
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Invalid JSON test should mock text() returning invalid JSON string.

This test mocks json() rejection, but the implementation calls text(). The test needs to return a non-JSON string from text() to trigger the parse error.

🐛 Proposed fix for invalid JSON test
   it('returns error for invalid JSON', async () => {
     vi.spyOn(globalThis, 'fetch').mockReturnValue(
       Promise.resolve({
         ok: true,
         status: 200,
-        json: () => Promise.reject(new SyntaxError('Unexpected token')),
+        text: () => Promise.resolve('{ invalid json }'),
       } as Response),
     );

     const result = await fetchWorkflowTemplate(template);
     expect(result.success).toBe(false);
     expect(result.error).toContain('Invalid template format');
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('returns error for invalid JSON', async () => {
vi.spyOn(globalThis, 'fetch').mockReturnValue(
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('Unexpected token')),
} as Response),
);
const result = await fetchWorkflowTemplate(template);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid template format');
});
it('returns error for invalid JSON', async () => {
vi.spyOn(globalThis, 'fetch').mockReturnValue(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve('{ invalid json }'),
} as Response),
);
const result = await fetchWorkflowTemplate(template);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid template format');
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/automations/utils/__tests__/fetch-workflow-template.test.ts`
around lines 108 - 120, The test for invalid JSON currently mocks fetch to
reject via json(), but fetchWorkflowTemplate reads response.text() and then
parses JSON; update the mock in the 'returns error for invalid JSON' test so the
mocked Response includes a text() method that returns a Promise resolving to a
non-JSON string (e.g. 'not json'), keep ok: true and status: 200, then assert
fetchWorkflowTemplate(template) returns success=false and error contains
'Invalid template format'; target the fetch mock in this test and the
fetchWorkflowTemplate function to ensure the parse path is exercised.

Comment on lines +52 to +56
try {
response = await fetch(url);
} catch {
return { success: false, error: 'Network error while fetching template' };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding a timeout to the fetch request.

The fetch call has no timeout, which could cause the request to hang indefinitely if the server is unresponsive. This is especially important for production where GitHub raw content is fetched.

♻️ Proposed fix to add timeout
   let response: Response;
   try {
-    response = await fetch(url);
+    response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
   } catch {
-    return { success: false, error: 'Network error while fetching template' };
+    return { success: false, error: 'Network error or timeout while fetching template' };
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
response = await fetch(url);
} catch {
return { success: false, error: 'Network error while fetching template' };
}
try {
response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
} catch {
return { success: false, error: 'Network error or timeout while fetching template' };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/automations/utils/fetch-workflow-template.ts`
around lines 52 - 56, Add a timeout using AbortController around the fetch(url)
call in fetch-workflow-template.ts so the request cannot hang indefinitely:
create an AbortController, pass its signal to fetch(url, { signal }), start a
setTimeout that calls controller.abort() after a configurable timeout (e.g.,
5s), and clear the timeout once fetch resolves; in the catch block distinguish
an abort error vs other network errors and return an appropriate error message
(e.g., "Request timed out while fetching template" for abort, otherwise the
existing network error) while keeping the existing response handling intact.

Comment on lines +41 to +66
{isLoading ? (
<Skeleton
className="h-5 w-5 rounded-full"
label="Loading automations count"
/>
) : (
<Badge variant="outline" className="text-xs">
{count}
</Badge>
)}
</>
}
>
{isLoading ? (
<div
className="mt-2 ml-6 space-y-2"
role="status"
aria-label="Loading automations"
>
<Skeleton
className="h-8 w-full rounded-md"
label="Loading automation"
/>
<Skeleton
className="h-8 w-3/4 rounded-md"
label="Loading automation"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Translate the loading labels.

The Skeleton labels and the loading aria-label are announced to screen readers, but they are hardcoded English strings. Route them through t(...) like the rest of this section so localized UIs do not mix languages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/settings/integrations/components/integration-manage/integration-related-automations.tsx`
around lines 41 - 66, Translate the hardcoded loading strings by routing them
through the i18n translator instead of plain English: replace the
aria-label="Loading automations" with
aria-label={t('settings.integrations.loadingAutomations')} and change each
Skeleton label prop (e.g., label="Loading automations count", label="Loading
automation") to label={t('settings.integrations.loadingAutomationsCount')},
label={t('settings.integrations.loadingAutomationSmall')} (or similar keys).
Ensure the component imports/uses the t function (e.g., from useTranslation) at
the top of integration-related-automations.tsx and update the translation
resource keys accordingly so screen-reader labels and Skeleton labels are
localized.

Comment on lines +81 to +129
await Promise.all(
[...rootDocs.values()].map(async (root) => {
// Check for active version
let activeVersion: { _id: Id<'wfDefinitions'> } | null = null;
for await (const v of ctx.db
.query('wfDefinitions')
.withIndex('by_root_status', (q) =>
q.eq('rootVersionId', root._id).eq('status', 'active'),
)) {
activeVersion = v;
break;
}

// If no active version found via rootVersionId, the root itself might be active
if (!activeVersion) {
const rootDoc = await ctx.db.get(root._id);
if (rootDoc?.status === 'active') {
activeVersion = rootDoc;
}
}

// Determine effective status
let status: 'draft' | 'active' | 'archived' = 'draft';
if (activeVersion) {
status = 'active';
} else {
// Check for archived
let hasArchived = false;
for await (const v of ctx.db
.query('wfDefinitions')
.withIndex('by_root_status', (q) =>
q.eq('rootVersionId', root._id).eq('status', 'archived'),
)) {
hasArchived = true;
break;
}
if (!hasArchived) {
const rootDoc = await ctx.db.get(root._id);
if (rootDoc?.status === 'archived') hasArchived = true;
}
if (hasArchived) status = 'archived';
}

results.push({
_id: root._id,
name: root.name,
status,
activeVersionId: activeVersion?._id ?? null,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only promote to an active version if that active version matched the integration.

This block marks a root automation as active whenever any active sibling exists. If only a stale draft/archived version references the integration and the active version removed it, the query still returns status: 'active' plus that unrelated activeVersionId, so the UI links to a version that no longer uses the integration. Compute status/target from the matched version IDs, or at least gate activeVersionId on the active version being part of the matched set.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/integrations/find_related_automations.ts` around
lines 81 - 129, The code currently marks a root as active whenever any sibling
wfDefinitions with status 'active' exists (activeVersion), even if that active
version does not reference the integration; instead ensure the chosen
activeVersion is one of the matched versions that reference the integration.
Change the logic in the map over [...rootDocs.values()] to first compute the
set/list of matched version IDs for this root (the versions returned by the
integration-matching query), then when searching for an activeVersion via
ctx.db.query('wfDefinitions').withIndex('by_root_status', ...) only accept an
activeVersion whose _id is in that matched set; similarly, when deciding
hasArchived or draft, base the determination on whether any matched version has
status 'archived' or 'draft' (or fall back to checking the root document only if
no matched versions exist). Update the values pushed into results (status and
activeVersionId) to reflect the matched-version-aware selection.

Comment on lines +107 to +118
function decodeBase64Url(data: string): string {
if (!data) return '';
let base64 = data.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
if (pad === 2) base64 += '==';
else if (pad === 3) base64 += '=';
try {
return atob(base64);
} catch {
return '';
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider UTF-8 decoding for non-ASCII email content.

atob() returns a binary string where each character represents a byte. For email bodies containing non-ASCII characters (UTF-8), this produces incorrect results. Gmail API returns UTF-8 encoded content in base64url.

🔧 Proposed fix for proper UTF-8 decoding
 function decodeBase64Url(data: string): string {
   if (!data) return '';
   let base64 = data.replace(/-/g, '+').replace(/_/g, '/');
   const pad = base64.length % 4;
   if (pad === 2) base64 += '==';
   else if (pad === 3) base64 += '=';
   try {
-    return atob(base64);
+    const binaryStr = atob(base64);
+    const bytes = Uint8Array.from(binaryStr, (c) => c.charCodeAt(0));
+    return new TextDecoder('utf-8').decode(bytes);
   } catch {
     return '';
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function decodeBase64Url(data: string): string {
if (!data) return '';
let base64 = data.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
if (pad === 2) base64 += '==';
else if (pad === 3) base64 += '=';
try {
return atob(base64);
} catch {
return '';
}
}
function decodeBase64Url(data: string): string {
if (!data) return '';
let base64 = data.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
if (pad === 2) base64 += '==';
else if (pad === 3) base64 += '=';
try {
const binaryStr = atob(base64);
const bytes = Uint8Array.from(binaryStr, (c) => c.charCodeAt(0));
return new TextDecoder('utf-8').decode(bytes);
} catch {
return '';
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/convex/workflow_engine/action_defs/conversation/helpers/normalize_email.ts`
around lines 107 - 118, The decodeBase64Url function currently uses atob() which
yields a binary string and corrupts non-ASCII UTF-8 content; update
decodeBase64Url to convert the base64url string to standard base64, decode to
raw bytes, then decode those bytes to a UTF-8 string using TextDecoder (or
equivalent) instead of returning the atob result directly; in other words, in
decodeBase64Url replace the try { return atob(base64); } catch { return ''; }
logic with code that calls atob(base64), builds a Uint8Array from charCodeAt for
each character, and feeds that array into new TextDecoder('utf-8').decode(...)
and return the result, still returning '' on errors so existing callers of
decodeBase64Url keep their semantics.

Comment on lines +187 to +202
export function normalizeEmail(data: unknown): EmailType {
if (isRawGmailMessage(data)) {
return gmailToEmailType(data);
}
return data as EmailType;
}

/**
* Normalize an array of email objects or a single email object.
*/
export function normalizeEmails(data: unknown): EmailType[] {
if (Array.isArray(data)) {
return data.map(normalizeEmail);
}
return [normalizeEmail(data)];
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Pass-through cast assumes valid input for non-Gmail data.

The as EmailType cast on line 191 trusts that non-Gmail input is already a valid EmailType. This is reasonable given the module's contract, but malformed input would propagate silently.

Consider adding a brief JSDoc note that callers are responsible for ensuring input is either raw Gmail or pre-mapped EmailType.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/convex/workflow_engine/action_defs/conversation/helpers/normalize_email.ts`
around lines 187 - 202, Add a short JSDoc above normalizeEmail (and mirror for
normalizeEmails) clarifying that the function accepts either a raw Gmail message
(detected by isRawGmailMessage) or a pre-mapped EmailType and that non-Gmail
inputs are assumed to already conform to EmailType (i.e., callers are
responsible for providing valid EmailType data); reference normalizeEmail,
normalizeEmails, EmailType, isRawGmailMessage, and gmailToEmailType in the
comment so future maintainers understand the cast behavior and caller
responsibility.

Comment on lines +24 to +31
server.middlewares.use('/workflow-templates', (req, res, next) => {
const filePath = path.join(workflowTemplatesDir, req.url ?? '');
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
res.setHeader('Content-Type', 'application/json');
fs.createReadStream(filePath).pipe(res);
} else {
next();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Path traversal vulnerability in dev server middleware.

The middleware joins req.url directly with workflowTemplatesDir without validating that the resolved path stays within the intended directory. A malicious request like /workflow-templates/../../../etc/passwd could access files outside examples/workflows.

While this only affects development mode, it's still a security concern worth addressing.

🛡️ Proposed fix to prevent path traversal
       server.middlewares.use('/workflow-templates', (req, res, next) => {
         const filePath = path.join(workflowTemplatesDir, req.url ?? '');
+        const realPath = path.resolve(filePath);
+        if (!realPath.startsWith(workflowTemplatesDir)) {
+          res.statusCode = 403;
+          res.end('Forbidden');
+          return;
+        }
         if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
           res.setHeader('Content-Type', 'application/json');
           fs.createReadStream(filePath).pipe(res);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
server.middlewares.use('/workflow-templates', (req, res, next) => {
const filePath = path.join(workflowTemplatesDir, req.url ?? '');
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
res.setHeader('Content-Type', 'application/json');
fs.createReadStream(filePath).pipe(res);
} else {
next();
}
server.middlewares.use('/workflow-templates', (req, res, next) => {
const filePath = path.join(workflowTemplatesDir, req.url ?? '');
const realPath = path.resolve(filePath);
if (!realPath.startsWith(workflowTemplatesDir)) {
res.statusCode = 403;
res.end('Forbidden');
return;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
res.setHeader('Content-Type', 'application/json');
fs.createReadStream(filePath).pipe(res);
} else {
next();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/vite.config.ts` around lines 24 - 31, The middleware
attached via server.middlewares.use that builds filePath with
path.join(workflowTemplatesDir, req.url) is vulnerable to path traversal; fix it
by resolving and validating the requested path before reading files:
normalize/strip query from req.url, compute const resolved =
path.resolve(workflowTemplatesDir, sanitizedReqUrl) and ensure
resolved.startsWith(path.resolve(workflowTemplatesDir) + path.sep) (or use
path.relative to ensure it does not escape), and only then call
fs.existsSync/fs.statSync and fs.createReadStream; if validation fails, call
next() to avoid serving files outside the workflowTemplatesDir.

- Remove unsafe type assertions in find_related_automations and normalize_email
- Use proper type narrowing with 'in' checks instead of 'as' casts
- Fix stepType to use union type instead of plain string
- Fix Button variant from "outline" to "secondary"
- Fix TanStack Router navigate search typing with Route.fullPath
- Rename path shadow variables in vite.config.ts proxy rewrites
- Remove unused Id import from test file
The component uses Convex queries that require QueryClientProvider,
which is not available in the integration-details test context.
The implementation calls response.text() + JSON.parse(), not
response.json(). Updated mocks to match.
- Await onTemplateSelected callback before releasing grid UI to prevent
  duplicate automation creation from double-clicks
- Only promote automation status to 'active' when the active version
  itself references the integration, not just a draft/archived sibling
- Prevent path traversal in dev workflow-templates middleware by
  validating resolved path stays within the templates directory
- Replace hardcoded English loading labels with translation keys
- Add missing translation keys for related automations and workflow
  template UI
…ion-templates

# Conflicts:
#	services/platform/messages/en.json
@Israeltheminer Israeltheminer merged commit b1f3fb3 into main Mar 17, 2026
17 checks passed
@Israeltheminer Israeltheminer deleted the feat/workflow-automation-templates branch March 17, 2026 11:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant