feat: workflow automation templates and integration-linked automations#805
Conversation
…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
There was a problem hiding this comment.
Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.
📝 WalkthroughWalkthroughThis 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)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
Comment 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. |
There was a problem hiding this comment.
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 | 🟠 MajorBlock tab changes while a create request is in flight.
The busy flags live inside each tab, but
Tabsstays 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 toCreateAutomationDialogContentand ignoreonValueChangewhile 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
⛔ Files ignored due to path filters (1)
services/platform/convex/_generated/api.d.tsis excluded by!**/_generated/**
📒 Files selected for processing (20)
examples/integrations/gmail/connector.tsexamples/workflows/gmail/email-sync.jsonexamples/workflows/outlook/email-sync.jsonservices/platform/app/features/automations/components/automation-create-dialog.tsxservices/platform/app/features/automations/components/workflow-template-grid.tsxservices/platform/app/features/automations/constants/workflow-templates.tsservices/platform/app/features/automations/utils/__tests__/fetch-workflow-template.test.tsservices/platform/app/features/automations/utils/fetch-workflow-template.tsservices/platform/app/features/settings/integrations/components/integration-details.tsxservices/platform/app/features/settings/integrations/components/integration-manage/integration-related-automations.tsxservices/platform/app/features/settings/integrations/hooks/use-integration-manage.tsservices/platform/app/routes/dashboard/$id/settings/integrations.tsxservices/platform/convex/integrations/__tests__/find_related_automations.test.tsservices/platform/convex/integrations/find_related_automations.tsservices/platform/convex/integrations/oauth2_token_exchange.tsservices/platform/convex/integrations/queries.tsservices/platform/convex/workflow_engine/action_defs/conversation/helpers/__tests__/normalize_email.test.tsservices/platform/convex/workflow_engine/action_defs/conversation/helpers/create_conversation_from_email.tsservices/platform/convex/workflow_engine/action_defs/conversation/helpers/normalize_email.tsservices/platform/vite.config.ts
| 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(), | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| interface WorkflowTemplateGridProps { | ||
| integrationName?: string; | ||
| onTemplateSelected: (data: WorkflowTemplateData) => void; |
There was a problem hiding this comment.
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.
| function mockResponse(body: string, ok = true) { | ||
| return Promise.resolve({ | ||
| ok, | ||
| status: ok ? 200 : 404, | ||
| json: () => Promise.resolve(JSON.parse(body)), | ||
| } as Response); | ||
| } |
There was a problem hiding this comment.
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:
- "fetches and parses a workflow template" — fails because
text()returns undefined - "caches successful results" — fails for the same reason
- "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.
| 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); | |
| } |
| 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.
| 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'); | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
| try { | ||
| response = await fetch(url); | ||
| } catch { | ||
| return { success: false, error: 'Network error while fetching template' }; | ||
| } |
There was a problem hiding this comment.
🧹 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.
| 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.
| {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" |
There was a problem hiding this comment.
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.
| 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, | ||
| }); |
There was a problem hiding this comment.
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.
| 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 ''; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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)]; | ||
| } |
There was a problem hiding this comment.
🧹 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.
| 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(); | ||
| } |
There was a problem hiding this comment.
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.
| 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
Summary
WorkflowTemplateGridcomponent with integration-based filteringCreateAutomationDialogwith "Blank" and "From template" tabsIntegrationRelatedAutomationssection in integration manage dialog shows linked automations with "Create automation" buttonfind_related_automationsquery to discover automations using a specific integrationget_threadfix: useformat=emailfor proper message normalizationformat=emailfromlist_messagesoperationsnormalize_emailhelper with comprehensive test coverageTest plan
bun run --filter @tale/platform test🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Improvements