diff --git a/src/lib/components/common-errors/common-error-list.svelte b/src/lib/components/common-errors/common-error-list.svelte
new file mode 100644
index 0000000000..6d525e0550
--- /dev/null
+++ b/src/lib/components/common-errors/common-error-list.svelte
@@ -0,0 +1,23 @@
+
+
+{#if errors.length > 0}
+
+ {#each errors as error (error.id)}
+
+ {/each}
+
+{/if}
diff --git a/src/lib/components/common-errors/common-error.svelte b/src/lib/components/common-errors/common-error.svelte
new file mode 100644
index 0000000000..6e6809803d
--- /dev/null
+++ b/src/lib/components/common-errors/common-error.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {error.description}
+ {error.action} →
+
diff --git a/src/lib/components/common-errors/common-errors-data.test.ts b/src/lib/components/common-errors/common-errors-data.test.ts
new file mode 100644
index 0000000000..8ef9b63236
--- /dev/null
+++ b/src/lib/components/common-errors/common-errors-data.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+
+import type {
+ CommonErrorCategory,
+ CommonErrorSeverity,
+} from '$lib/types/common-errors';
+
+import {
+ COMMON_ERRORS,
+ getCommonErrorById,
+ getCommonErrorsByCategory,
+} from './common-errors-data';
+
+const VALID_SEVERITIES: CommonErrorSeverity[] = ['error', 'warning', 'info'];
+
+const VALID_CATEGORIES: CommonErrorCategory[] = [
+ 'workflow-timeouts',
+ 'continue-as-new',
+ 'retry-policies',
+ 'activity-timeouts',
+ 'heartbeat',
+ 'delayed-start',
+ 'local-activities',
+ 'event-history',
+ 'multiple-payloads',
+ 'workflow-id-reuse',
+ 'memo-headers',
+];
+
+describe('COMMON_ERRORS', () => {
+ it('contains 35 errors', () => {
+ expect(COMMON_ERRORS).toHaveLength(35);
+ });
+
+ it('has no duplicate IDs', () => {
+ const ids = COMMON_ERRORS.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('has valid severity for every error', () => {
+ for (const error of COMMON_ERRORS) {
+ expect(VALID_SEVERITIES).toContain(error.severity);
+ }
+ });
+
+ it('has valid category for every error', () => {
+ for (const error of COMMON_ERRORS) {
+ expect(VALID_CATEGORIES).toContain(error.category);
+ }
+ });
+
+ it('has required fields for every error', () => {
+ for (const error of COMMON_ERRORS) {
+ expect(error.id).toBeTypeOf('number');
+ expect(error.title).toBeTruthy();
+ expect(error.description).toBeTruthy();
+ expect(error.link).toBeTruthy();
+ expect(error.action).toBeTruthy();
+ expect(error.category).toBeTruthy();
+ }
+ });
+
+ it('has valid links for every error', () => {
+ for (const error of COMMON_ERRORS) {
+ expect(error.link).toMatch(/^https:\/\/docs\.temporal\.io\//);
+ }
+ });
+});
+
+describe('getCommonErrorById', () => {
+ it('returns the correct error for a valid ID', () => {
+ const error = getCommonErrorById(1);
+ expect(error).toBeDefined();
+ expect(error?.id).toBe(1);
+ expect(error?.title).toBe('Workflow Execution Timeout Set');
+ });
+
+ it('returns undefined for an invalid ID', () => {
+ expect(getCommonErrorById(999)).toBeUndefined();
+ });
+
+ it('returns undefined for ID 0', () => {
+ expect(getCommonErrorById(0)).toBeUndefined();
+ });
+});
+
+describe('getCommonErrorsByCategory', () => {
+ it('returns only errors matching the category', () => {
+ const errors = getCommonErrorsByCategory('workflow-timeouts');
+ expect(errors.length).toBeGreaterThan(0);
+ for (const error of errors) {
+ expect(error.category).toBe('workflow-timeouts');
+ }
+ });
+
+ it('returns 6 workflow-timeouts errors', () => {
+ expect(getCommonErrorsByCategory('workflow-timeouts')).toHaveLength(6);
+ });
+
+ it('returns 3 continue-as-new errors', () => {
+ expect(getCommonErrorsByCategory('continue-as-new')).toHaveLength(3);
+ });
+
+ it('returns errors for memo-headers category', () => {
+ expect(getCommonErrorsByCategory('memo-headers')).toHaveLength(1);
+ });
+});
diff --git a/src/lib/components/common-errors/common-errors-data.ts b/src/lib/components/common-errors/common-errors-data.ts
new file mode 100644
index 0000000000..46c0b893e8
--- /dev/null
+++ b/src/lib/components/common-errors/common-errors-data.ts
@@ -0,0 +1,378 @@
+import type {
+ CommonError,
+ CommonErrorCategory,
+ CommonErrorSeverity,
+} from '$lib/types/common-errors';
+
+export const COMMON_ERRORS: CommonError[] = [
+ {
+ id: 1,
+ severity: 'warning',
+ title: 'Workflow Execution Timeout Set',
+ description:
+ 'Workers cannot react to this timeout. When it fires, no cancellation or cleanup logic will run, and any pending activities will continue executing on your Workers. Consider using a Workflow timer with a detached cancellation scope or sending a cancellation request from the client for more controlled shutdowns.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-workflow-failures',
+ action: 'Workflow Execution Timeout docs',
+ category: 'workflow-timeouts',
+ },
+ {
+ id: 2,
+ severity: 'error',
+ title: 'Execution Timeout Less Than or Equal to Run Timeout',
+ description:
+ 'The Workflow Execution Timeout should be longer than the Workflow Run Timeout. When they are equal or the Execution Timeout is shorter, the two effectively cancel each other out and the behavior may not match expectations.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-workflow-failures',
+ action: 'Workflow Timeouts docs',
+ category: 'workflow-timeouts',
+ },
+ {
+ id: 3,
+ severity: 'warning',
+ title: 'Very Short Execution/Run Timeout',
+ description:
+ "Setting a Workflow Execution or Run Timeout less than 1–2 minutes can create a large volume of server-side Timers, especially at scale. This may put pressure on your Namespace's infrastructure and could cause noisy neighbor issues for other workloads.",
+ link: 'https://docs.temporal.io/encyclopedia/detecting-workflow-failures',
+ action: 'Workflow Timeouts docs',
+ category: 'workflow-timeouts',
+ },
+ {
+ id: 4,
+ severity: 'warning',
+ title: 'Workflow Task Timeout Changed from Default',
+ description:
+ 'The Workflow Task Timeout is set to a value other than the default of 10 seconds. Changing this is typically not recommended unless you have a specific low-latency use case. Increasing it can mask blocking code in your Workflow and delay the delivery of Signals and Updates.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-workflow-failures',
+ action: 'Workflow Task Timeout docs',
+ category: 'workflow-timeouts',
+ },
+ {
+ id: 5,
+ severity: 'error',
+ title: 'Workflow Task Timeout Increased Above 10 Seconds',
+ description:
+ 'Setting the Workflow Task Timeout above 10 seconds is strongly discouraged. Blocking code in your Workflow may go undetected and Signal delivery will be delayed because Signals are only delivered on Workflow Task boundaries.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-workflow-failures',
+ action: 'Workflow Task Timeout docs',
+ category: 'workflow-timeouts',
+ },
+ {
+ id: 6,
+ severity: 'warning',
+ title: 'Workflow Task Timeout Decreased Below 10 Seconds',
+ description:
+ 'The Workflow Task Timeout has been lowered below the default. This can be appropriate for low-latency use cases with strict SLOs, but make sure your Workflow logic can consistently complete within this window.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-workflow-failures',
+ action: 'Workflow Task Timeout docs',
+ category: 'workflow-timeouts',
+ },
+ {
+ id: 7,
+ severity: 'warning',
+ title: 'High-Frequency Continue-as-New Detected',
+ description:
+ 'This Workflow Execution completed and Continued-as-New in under 2 seconds. High-frequency Continue-as-New puts repeated pressure on the same infrastructure shard because the Workflow ID is preserved. Consider using a loop inside your Workflow code instead. Temporal fully supports deterministic loops.',
+ link: 'https://docs.temporal.io/workflow-execution/continue-as-new',
+ action: 'Continue-as-New docs',
+ category: 'continue-as-new',
+ },
+ {
+ id: 8,
+ severity: 'info',
+ title: 'Continue-as-New Cost Consideration',
+ description:
+ 'Each Continue-as-New creates a new billable Workflow Execution. If you are iterating over items one at a time with Continue-as-New, processing them in a loop within a single Workflow Execution will reduce your costs significantly.',
+ link: 'https://docs.temporal.io/workflow-execution/continue-as-new',
+ action: 'Continue-as-New docs',
+ category: 'continue-as-new',
+ cloudOnly: true,
+ },
+ {
+ id: 9,
+ severity: 'info',
+ title: 'Consider Using Suggest Continue-as-New Instead',
+ description:
+ "Rather than triggering Continue-as-New on a fixed schedule, you can rely on the server's suggest_continue_as_new flag, which is set at approximately 4,000 events. This approach ensures you only Continue-as-New when the Event History is actually getting large.",
+ link: 'https://docs.temporal.io/workflow-execution/continue-as-new',
+ action: 'SuggestContinueAsNew docs',
+ category: 'continue-as-new',
+ },
+ {
+ id: 10,
+ severity: 'warning',
+ title: 'Workflow Retry Policy Defined',
+ description:
+ "This Workflow has a Retry Policy configured. Over 99% of use cases don't require Workflow-level retries. Consider handling errors in your Workflow code and using Continue-as-New or Workflow Reset to recover from failures.",
+ link: 'https://docs.temporal.io/encyclopedia/retry-policies',
+ action: 'Workflow Retry Policy docs',
+ category: 'retry-policies',
+ },
+ {
+ id: 11,
+ severity: 'warning',
+ title: 'Workflow Retries Start from the Beginning',
+ description:
+ 'A Workflow retry does not resume from the point of failure. It restarts the entire Workflow from the very first step. If you need to resume from where the Workflow failed, use Workflow Reset instead.',
+ link: 'https://docs.temporal.io/encyclopedia/retry-policies',
+ action: 'Workflow Reset docs',
+ category: 'retry-policies',
+ },
+ {
+ id: 12,
+ severity: 'warning',
+ title: 'Child Workflow Retry Policy Defined',
+ description:
+ 'This Child Workflow has a Retry Policy configured. Over 99% of use cases do not require Workflow-level retries, and this applies to Child Workflows as well. A retry will restart the Child Workflow from the very beginning, not from the point of failure. Consider handling errors in your Workflow code and using Continue-as-New or Workflow Reset to recover from failures instead.',
+ link: 'https://docs.temporal.io/encyclopedia/retry-policies',
+ action: 'Child Workflow docs',
+ category: 'retry-policies',
+ },
+ {
+ id: 13,
+ severity: 'warning',
+ title: 'No Explicit Activity Retry Policy (Infinite Retries)',
+ description:
+ 'This Activity does not have an explicit Retry Policy, which means it will use the default: unlimited retries with no maximum attempt count. If the Activity keeps failing, it will retry indefinitely, which can lead to unexpected costs and Activities that appear stuck.',
+ link: 'https://docs.temporal.io/encyclopedia/retry-policies',
+ action: 'Activity Retry Policy docs',
+ category: 'retry-policies',
+ cloudOnly: true,
+ },
+ {
+ id: 14,
+ severity: 'error',
+ title: 'Activity Max Attempts Set to 1 (Retries Disabled)',
+ description:
+ 'Setting Max Attempts to 1 disables Activity retries entirely. Activities have an at-least-once execution guarantee, meaning the Activity Task can be lost over the network between the server and your Worker. Without retries, the Activity may never execute. If you need to prevent duplicate executions, consider using Non-Retryable Error Types for specific failure conditions instead.',
+ link: 'https://docs.temporal.io/encyclopedia/retry-policies',
+ action: 'Activity Retry Policy docs',
+ category: 'retry-policies',
+ },
+ {
+ id: 15,
+ severity: 'info',
+ title: 'Consider Using Non-Retryable Error Types',
+ description:
+ 'If you want to stop Activity retries for specific error conditions rather than disabling retries entirely, you can throw a Non-Retryable Application Failure from your Activity code. This gives you fine-grained control over which failures should and should not be retried.',
+ link: 'https://docs.temporal.io/encyclopedia/retry-policies',
+ action: 'Non-Retryable Errors docs',
+ category: 'retry-policies',
+ },
+ {
+ id: 16,
+ severity: 'warning',
+ title: 'Activity Start-to-Close Timeout Less Than or Equal to 1 Second',
+ description:
+ "The Start-to-Close timeout includes not just your Activity's execution time but also the network round-trip between your Worker and the server. Setting it to 1 second or less leaves almost no margin and may cause premature timeouts even when your Activity completes successfully.",
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Activity Timeouts docs',
+ category: 'activity-timeouts',
+ },
+ {
+ id: 17,
+ severity: 'error',
+ title: 'Start-to-Close Timeout Not Set',
+ description:
+ 'You have set a Schedule-to-Close timeout but not a Start-to-Close timeout. Start-to-Close is the most important Activity timeout to set because it governs individual retry attempts. Without it, a single stuck attempt can consume the entire Schedule-to-Close window.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Activity Timeouts docs',
+ category: 'activity-timeouts',
+ },
+ {
+ id: 18,
+ severity: 'warning',
+ title: 'Schedule-to-Close Too Close to Start-to-Close',
+ description:
+ 'The Schedule-to-Close Timeout is not significantly larger than the Start-to-Close Timeout. This leaves little or no room for retry attempts. For retries to be effective, Schedule-to-Close should be a meaningful multiple of Start-to-Close.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Activity Timeouts docs',
+ category: 'activity-timeouts',
+ },
+ {
+ id: 19,
+ severity: 'error',
+ title: 'Schedule-to-Close Exceeds Workflow Run Timeout',
+ description:
+ "Your Activity's Schedule-to-Close Timeout is longer than the Workflow Run Timeout. The server will silently cap the Schedule-to-Close to the Workflow Run Timeout value, which means your Activity may time out sooner than you expect.",
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Activity Timeouts docs',
+ category: 'activity-timeouts',
+ },
+ {
+ id: 20,
+ severity: 'warning',
+ title: 'Schedule-to-Start Timeout Set',
+ description:
+ 'The Schedule-to-Start Timeout is a non-retryable timeout. If your Workers are slow to pick up the Activity Task or restart during this window, the Activity will fail permanently with no retry. This timeout is only recommended for specialized use cases like dynamic Task Queue routing.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Schedule-to-Start Timeout docs',
+ category: 'activity-timeouts',
+ },
+ {
+ id: 21,
+ severity: 'error',
+ title: 'Heartbeat Timeout Greater Than or Equal to Start-to-Close Timeout',
+ description:
+ 'Your Heartbeat Timeout is greater than or equal to your Start-to-Close Timeout. The Heartbeat will never have a chance to fire before the Activity times out, making it ineffective. Heartbeat Timeout should be well under your Start-to-Close Timeout (roughly 80% or less).',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Activity Heartbeat docs',
+ category: 'heartbeat',
+ },
+ {
+ id: 22,
+ severity: 'warning',
+ title: 'Heartbeat Timeout Should Be Less Than Start-to-Close',
+ description:
+ 'For heartbeating to be effective at detecting stuck Activities, the Heartbeat Timeout should be meaningfully shorter than the Start-to-Close Timeout. A good guideline is to set it to about 80% or less of the Start-to-Close value.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Activity Heartbeat docs',
+ category: 'heartbeat',
+ },
+ {
+ id: 23,
+ severity: 'warning',
+ title: 'Heartbeat Timeout Set but No Heartbeats Detected',
+ description:
+ 'A Heartbeat Timeout is configured for this Activity, but no Heartbeat events have been recorded. If your Activity code does not call the Heartbeat API, the timeout will eventually fire and cause the Activity to fail. Either add Heartbeat calls to your Activity or remove the Heartbeat Timeout.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Activity Heartbeat docs',
+ category: 'heartbeat',
+ },
+ {
+ id: 24,
+ severity: 'warning',
+ title: 'Very Long Delayed Start (First Workflow Task Backoff)',
+ description:
+ 'This Workflow has a First Workflow Task Backoff set to a very long duration. The Workflow Execution will not begin processing until this delay expires. Please verify this is intentional.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Delayed Start docs',
+ category: 'delayed-start',
+ },
+ {
+ id: 25,
+ severity: 'warning',
+ title: 'Very Short Delayed Start',
+ description:
+ 'Setting a First Workflow Task Backoff of less than 1 second provides minimal practical delay and creates a server-side timer that must be managed. At high volume, sub-second delayed starts can put unnecessary pressure on infrastructure.',
+ link: 'https://docs.temporal.io/encyclopedia/detecting-activity-failures',
+ action: 'Delayed Start docs',
+ category: 'delayed-start',
+ },
+ {
+ id: 26,
+ severity: 'warning',
+ title: 'Local Activity Extending Workflow Task',
+ description:
+ 'A repeating pattern of WorkflowTaskScheduled, WorkflowTaskStarted, and WorkflowTaskCompleted events indicates a Local Activity is running longer than the Workflow Task Timeout and extending the task. Each extension is billed as a Workflow Task Heartbeat. Consider whether this Activity should be a regular (non-local) Activity instead.',
+ link: 'https://docs.temporal.io/local-activity',
+ action: 'Local Activities docs',
+ category: 'local-activities',
+ cloudOnly: true,
+ },
+ {
+ id: 27,
+ severity: 'info',
+ title: 'Local Activities Not Batched (Cost Optimization)',
+ description:
+ "Each Local Activity in this Workflow is completing in its own separate Workflow Task rather than being bundled together. You lose the primary cost benefit of Local Activities when they aren't batched. Try to group multiple Local Activities within a single Workflow Task window (default 10 seconds) by accumulating work before executing.",
+ link: 'https://docs.temporal.io/local-activity',
+ action: 'Local Activities docs',
+ category: 'local-activities',
+ cloudOnly: true,
+ },
+ {
+ id: 28,
+ severity: 'warning',
+ title: 'Local Activity with Unlimited Retries',
+ description:
+ "Local Activity retries generate events in the Workflow's Event History. If a Local Activity retries indefinitely, it can rapidly fill the Event History and push you toward the Continue-as-New threshold. Set a bounded Retry Policy for Local Activities.",
+ link: 'https://docs.temporal.io/local-activity',
+ action: 'Local Activities docs',
+ category: 'local-activities',
+ },
+ {
+ id: 29,
+ severity: 'info',
+ title: 'Local Activities May Delay Signal Processing',
+ description:
+ 'While Local Activities are running, Signal and Update delivery can be delayed because they are processed on Workflow Task boundaries. If timely Signal handling is critical, consider using regular Activities or restructuring your Workflow to process Signals between Local Activity batches.',
+ link: 'https://docs.temporal.io/local-activity',
+ action: 'Local Activities docs',
+ category: 'local-activities',
+ },
+ {
+ id: 30,
+ severity: 'warning',
+ title: 'Local Activities Fully Retry on Workflow Task Failure',
+ description:
+ 'If a Workflow Task times out or fails while Local Activities are in progress, those Local Activities will be completely retried from the beginning. Idempotency in your Local Activity code is critical to avoid duplicate side effects.',
+ link: 'https://docs.temporal.io/local-activity',
+ action: 'Local Activities docs',
+ category: 'local-activities',
+ },
+ {
+ id: 31,
+ severity: 'warning',
+ title: 'Suggest Continue-as-New Was Ignored',
+ description:
+ 'The server set the suggest_continue_as_new flag at approximately 4,000 events, but this Workflow continued for a significant number of additional events. Large Event Histories increase replay time and memory consumption on your Workers. Implement a check for this flag in your Workflow code and call Continue-as-New when it is set.',
+ link: 'https://docs.temporal.io/workflow-execution/continue-as-new',
+ action: 'SuggestContinueAsNew docs',
+ category: 'event-history',
+ },
+ {
+ id: 32,
+ severity: 'info',
+ title: 'Multiple Input Payloads Detected',
+ description:
+ 'This Workflow/Activity receives multiple input payloads. We recommend using a single composite payload (such as a struct or object) instead. During replay, the Data Converter is called once per payload, so multiple payloads increase replay overhead.',
+ link: 'https://docs.temporal.io/dataconversion',
+ action: 'Data Converter docs',
+ category: 'multiple-payloads',
+ },
+ {
+ id: 33,
+ severity: 'error',
+ title: 'Workflow ID Reuse Policy: Terminate-If-Running',
+ description:
+ 'This Workflow uses the "terminate-if-running" ID Reuse Policy. Starting a new Workflow with the same ID will forcibly terminate the currently running execution. This can cause data loss or incomplete processing if the running Workflow is mid-operation. Make sure this behavior is intentional.',
+ link: 'https://docs.temporal.io/workflow-execution/workflowid-runid',
+ action: 'Workflow ID Reuse Policy docs',
+ category: 'workflow-id-reuse',
+ },
+ {
+ id: 34,
+ severity: 'warning',
+ title: 'Workflow ID Reuse Policy Limited by Retention',
+ description:
+ "Your Workflow ID Reuse Policy (reject-duplicate or failed-only) is only enforced while the previous execution's records exist in the system. Once the Namespace Retention Period expires and the old execution is purged, the server can no longer enforce this check. If you need deduplication guarantees beyond your retention window, consider maintaining an external record.",
+ link: 'https://docs.temporal.io/workflow-execution/workflowid-runid',
+ action: 'Namespace Retention docs',
+ category: 'workflow-id-reuse',
+ },
+ {
+ id: 35,
+ severity: 'info',
+ title: 'Sensitive Data in Memo (Consider Using Headers)',
+ description:
+ 'Memo fields are stored as plaintext and are visible in the UI and API responses. If you are passing sensitive information such as PII, consider using headers instead. Header values pass through the Data Converter, which supports encryption, and can be propagated using SDK interceptors.',
+ link: 'https://docs.temporal.io/dataconversion',
+ action: 'Headers and Context Propagation docs',
+ category: 'memo-headers',
+ },
+];
+
+export function getCommonErrorById(id: number): CommonError | undefined {
+ return COMMON_ERRORS.find((error) => error.id === id);
+}
+
+export function getCommonErrorsByCategory(
+ category: CommonErrorCategory,
+): CommonError[] {
+ return COMMON_ERRORS.filter((error) => error.category === category);
+}
+
+export function getCommonErrorsBySeverity(
+ severity: CommonErrorSeverity,
+): CommonError[] {
+ return COMMON_ERRORS.filter((error) => error.severity === severity);
+}
diff --git a/src/lib/components/workflow/workflow-common-errors.svelte b/src/lib/components/workflow/workflow-common-errors.svelte
new file mode 100644
index 0000000000..87f3974790
--- /dev/null
+++ b/src/lib/components/workflow/workflow-common-errors.svelte
@@ -0,0 +1,22 @@
+
+
+{#if errors.length > 0}
+
+{/if}
diff --git a/src/lib/holocene/alert.stories.svelte b/src/lib/holocene/alert.stories.svelte
index 6987442959..9f0f0cc36a 100644
--- a/src/lib/holocene/alert.stories.svelte
+++ b/src/lib/holocene/alert.stories.svelte
@@ -1,5 +1,8 @@
-
diff --git a/src/lib/holocene/alert.svelte b/src/lib/holocene/alert.svelte
index fd1e33e7ac..9e2ebbb1a8 100644
--- a/src/lib/holocene/alert.svelte
+++ b/src/lib/holocene/alert.svelte
@@ -1,6 +1,7 @@
@@ -215,6 +241,6 @@
{:else if !$workflowRun.workflow}
{:else}
-
-
+
+ {@render children()}
{/if}
diff --git a/src/lib/types/common-errors.ts b/src/lib/types/common-errors.ts
new file mode 100644
index 0000000000..7451092fe5
--- /dev/null
+++ b/src/lib/types/common-errors.ts
@@ -0,0 +1,25 @@
+export type CommonErrorSeverity = 'error' | 'warning' | 'info';
+
+export type CommonErrorCategory =
+ | 'workflow-timeouts'
+ | 'continue-as-new'
+ | 'retry-policies'
+ | 'activity-timeouts'
+ | 'heartbeat'
+ | 'delayed-start'
+ | 'local-activities'
+ | 'event-history'
+ | 'multiple-payloads'
+ | 'workflow-id-reuse'
+ | 'memo-headers';
+
+export interface CommonError {
+ id: number;
+ severity: CommonErrorSeverity;
+ title: string;
+ description: string;
+ link: string;
+ action: string;
+ category: CommonErrorCategory;
+ cloudOnly?: boolean;
+}
diff --git a/src/lib/utilities/common-error-detection.test.ts b/src/lib/utilities/common-error-detection.test.ts
new file mode 100644
index 0000000000..b58552dd38
--- /dev/null
+++ b/src/lib/utilities/common-error-detection.test.ts
@@ -0,0 +1,929 @@
+import { describe, expect, it } from 'vitest';
+
+import type { PendingActivity, WorkflowEvent } from '$lib/types/events';
+import type { WorkflowExecution } from '$lib/types/workflows';
+
+import {
+ detectActivityErrors,
+ detectEventHistoryErrors,
+ detectFirstEventErrors,
+ detectWorkflowErrors,
+ durationToSeconds,
+ getApplicableCommonErrors,
+} from './common-error-detection';
+
+const makeWorkflow = (
+ overrides: Partial = {},
+): WorkflowExecution =>
+ ({
+ name: 'test-workflow',
+ id: 'test-id',
+ runId: 'test-run-id',
+ startTime: '2024-01-01T00:00:00Z',
+ endTime: '2024-01-01T01:00:00Z',
+ executionTime: '2024-01-01T00:00:00Z',
+ status: 'Running',
+ historyEvents: '100',
+ historySizeBytes: '1000',
+ defaultWorkflowTaskTimeout: '10s',
+ pendingActivities: [],
+ pendingChildren: [],
+ pendingNexusOperations: [],
+ callbacks: [],
+ isRunning: true,
+ isPaused: false,
+ canBeTerminated: true,
+ stateTransitionCount: '1',
+ url: '/workflows/test-id/test-run-id',
+ memo: {},
+ workflowExtendedInfo: {},
+ ...overrides,
+ }) as unknown as WorkflowExecution;
+
+const makeActivity = (
+ overrides: Partial = {},
+): PendingActivity =>
+ ({
+ id: '1',
+ state: 'Started',
+ activityType: 'test-activity',
+ ...overrides,
+ }) as unknown as PendingActivity;
+
+const makeFirstEvent = (attrs: Record = {}) =>
+ ({
+ eventType: 'WorkflowExecutionStarted',
+ workflowExecutionStartedEventAttributes: {
+ workflowExecutionTimeout: '0s',
+ workflowRunTimeout: '0s',
+ workflowTaskTimeout: '10s',
+ retryPolicy: null,
+ input: { payloads: ['arg1'] },
+ workflowIdReusePolicy: 'WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE',
+ firstWorkflowTaskBackoff: '0s',
+ ...attrs,
+ },
+ attributes: {
+ type: 'workflowExecutionStartedEventAttributes',
+ },
+ }) as unknown;
+
+const makeEvent = (
+ eventType: string,
+ attrs: Record = {},
+): WorkflowEvent => {
+ const attrKey =
+ eventType.charAt(0).toLowerCase() + eventType.slice(1) + 'EventAttributes';
+ return {
+ eventType,
+ [attrKey]: attrs,
+ attributes: { type: attrKey },
+ } as unknown as WorkflowEvent;
+};
+
+const makeLocalActivityMarker = (): WorkflowEvent =>
+ ({
+ eventType: 'MarkerRecorded',
+ markerRecordedEventAttributes: {
+ markerName: 'LocalActivity',
+ },
+ attributes: { type: 'markerRecordedEventAttributes' },
+ }) as unknown as WorkflowEvent;
+
+describe('durationToSeconds', () => {
+ it('returns 0 for null/undefined/empty', () => {
+ expect(durationToSeconds(null)).toBe(0);
+ expect(durationToSeconds(undefined)).toBe(0);
+ expect(durationToSeconds('')).toBe(0);
+ });
+
+ it('parses protobuf Duration objects with nanos', () => {
+ expect(durationToSeconds({ seconds: '10', nanos: 500000000 })).toBe(10.5);
+ expect(durationToSeconds({ seconds: '0', nanos: 500000000 })).toBe(0.5);
+ expect(durationToSeconds({ seconds: '3600' })).toBe(3600);
+ });
+
+ it('parses "Ns" format', () => {
+ expect(durationToSeconds('10s')).toBe(10);
+ expect(durationToSeconds('3600s')).toBe(3600);
+ expect(durationToSeconds('0s')).toBe(0);
+ expect(durationToSeconds('0.5s')).toBe(0.5);
+ });
+
+ it('parses human-readable single unit', () => {
+ expect(durationToSeconds('10 seconds')).toBe(10);
+ expect(durationToSeconds('1 second')).toBe(1);
+ expect(durationToSeconds('5 minutes')).toBe(300);
+ expect(durationToSeconds('1 minute')).toBe(60);
+ expect(durationToSeconds('2 hours')).toBe(7200);
+ expect(durationToSeconds('1 hour')).toBe(3600);
+ expect(durationToSeconds('1 day')).toBe(86400);
+ expect(durationToSeconds('500 milliseconds')).toBe(0.5);
+ });
+
+ it('parses human-readable multi-part', () => {
+ expect(durationToSeconds('1 hour, 30 minutes')).toBe(5400);
+ expect(durationToSeconds('2 minutes, 30 seconds')).toBe(150);
+ expect(durationToSeconds('1 hour, 2 minutes, 3 seconds')).toBe(3723);
+ });
+});
+
+describe('detectWorkflowErrors', () => {
+ it('returns empty for healthy workflow', () => {
+ const workflow = makeWorkflow();
+ expect(detectWorkflowErrors(workflow)).toHaveLength(0);
+ });
+
+ it('detects #1: workflow execution timeout set', () => {
+ const workflow = makeWorkflow({ workflowExecutionTimeout: '3600s' });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 1)).toBe(true);
+ });
+
+ it('detects #3: very short execution timeout (< 120s)', () => {
+ const workflow = makeWorkflow({ workflowExecutionTimeout: '60s' });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 1)).toBe(true);
+ expect(errors.some((e) => e.id === 3)).toBe(true);
+ });
+
+ it('does not fire #3 for timeout >= 120s', () => {
+ const workflow = makeWorkflow({ workflowExecutionTimeout: '3600s' });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 3)).toBe(false);
+ });
+
+ it('detects #4 + #5: task timeout > 10s', () => {
+ const workflow = makeWorkflow({ defaultWorkflowTaskTimeout: '30s' });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 4)).toBe(true);
+ expect(errors.some((e) => e.id === 5)).toBe(true);
+ });
+
+ it('detects #4 + #6: task timeout < 10s', () => {
+ const workflow = makeWorkflow({ defaultWorkflowTaskTimeout: '5s' });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 4)).toBe(true);
+ expect(errors.some((e) => e.id === 6)).toBe(true);
+ });
+
+ it('does not fire #4 for default 10s', () => {
+ const workflow = makeWorkflow({ defaultWorkflowTaskTimeout: '10s' });
+ expect(detectWorkflowErrors(workflow)).toHaveLength(0);
+ });
+
+ it('detects #7: ContinuedAsNew with < 2s duration', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ startTime: '2024-01-01T00:00:00.000Z',
+ endTime: '2024-01-01T00:00:01.500Z',
+ });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 7)).toBe(true);
+ });
+
+ it('does not fire #7 for ContinuedAsNew with >= 2s', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ startTime: '2024-01-01T00:00:00.000Z',
+ endTime: '2024-01-01T00:00:05.000Z',
+ });
+ expect(detectWorkflowErrors(workflow).some((e) => e.id === 7)).toBe(false);
+ });
+
+ it('detects #9: ContinuedAsNew with low event count (< 2000)', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ historyEvents: '500',
+ });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 9)).toBe(true);
+ });
+
+ it('does not fire #9 for ContinuedAsNew with >= 2000 events', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ historyEvents: '3000',
+ });
+ expect(detectWorkflowErrors(workflow).some((e) => e.id === 9)).toBe(false);
+ });
+
+ it('does not fire #9 for non-ContinuedAsNew workflows', () => {
+ const workflow = makeWorkflow({
+ status: 'Completed',
+ historyEvents: '500',
+ });
+ expect(detectWorkflowErrors(workflow).some((e) => e.id === 9)).toBe(false);
+ });
+
+ it('detects #24: very long start delay (> 24h)', () => {
+ const workflow = makeWorkflow({ startDelay: '100000s' });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 24)).toBe(true);
+ });
+
+ it('detects #25: very short start delay (< 1s)', () => {
+ const workflow = makeWorkflow({ startDelay: '0s' });
+ expect(detectWorkflowErrors(workflow).some((e) => e.id === 25)).toBe(false);
+
+ const workflow2 = makeWorkflow({ startDelay: '1s' });
+ expect(detectWorkflowErrors(workflow2).some((e) => e.id === 25)).toBe(
+ false,
+ );
+ });
+
+ it('detects #31: history events > 10000', () => {
+ const workflow = makeWorkflow({ historyEvents: '15000' });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 31)).toBe(true);
+ });
+
+ it('does not fire #31 for <= 10000 events', () => {
+ const workflow = makeWorkflow({ historyEvents: '10000' });
+ expect(detectWorkflowErrors(workflow).some((e) => e.id === 31)).toBe(false);
+ });
+
+ it('detects #35: sensitive memo field names', () => {
+ const workflow = makeWorkflow({
+ memo: { fields: { ssn: {}, normalField: {} } },
+ });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 35)).toBe(true);
+ });
+
+ it('detects #35: password in memo field name', () => {
+ const workflow = makeWorkflow({
+ memo: { fields: { user_password: {} } },
+ });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 35)).toBe(true);
+ });
+
+ it('detects #35: api_key in memo field name', () => {
+ const workflow = makeWorkflow({
+ memo: { fields: { api_key: {} } },
+ });
+ const errors = detectWorkflowErrors(workflow);
+ expect(errors.some((e) => e.id === 35)).toBe(true);
+ });
+
+ it('does not fire #35 for normal memo field names', () => {
+ const workflow = makeWorkflow({
+ memo: { fields: { status: {}, count: {}, result: {} } },
+ });
+ expect(detectWorkflowErrors(workflow).some((e) => e.id === 35)).toBe(false);
+ });
+
+ it('does not fire #35 for empty memo', () => {
+ const workflow = makeWorkflow({ memo: {} });
+ expect(detectWorkflowErrors(workflow).some((e) => e.id === 35)).toBe(false);
+ });
+});
+
+describe('detectActivityErrors', () => {
+ it('returns empty for no activities', () => {
+ expect(detectActivityErrors([])).toHaveLength(0);
+ });
+
+ it('detects #13: no retry policy on activity', () => {
+ const activity = makeActivity({});
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 13)).toBe(true);
+ });
+
+ it('detects #13: maximumAttempts is 0 (unlimited)', () => {
+ const activity = makeActivity({
+ retryPolicy: { maximumAttempts: 0 },
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 13)).toBe(true);
+ });
+
+ it('does not fire #13 when maximumAttempts > 0', () => {
+ const activity = makeActivity({
+ retryPolicy: { maximumAttempts: 5 },
+ });
+ expect(detectActivityErrors([activity]).some((e) => e.id === 13)).toBe(
+ false,
+ );
+ });
+
+ it('detects #14: maximumAttempts == 1', () => {
+ const activity = makeActivity({
+ retryPolicy: { maximumAttempts: 1 },
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 14)).toBe(true);
+ });
+
+ it('does not fire #14 for maximumAttempts > 1', () => {
+ const activity = makeActivity({
+ retryPolicy: { maximumAttempts: 3 },
+ });
+ expect(detectActivityErrors([activity]).some((e) => e.id === 14)).toBe(
+ false,
+ );
+ });
+
+ it('detects #15: activity retrying with attempt > 3', () => {
+ const activity = makeActivity({
+ attempt: 5,
+ retryPolicy: { maximumAttempts: 10 },
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 15)).toBe(true);
+ });
+
+ it('does not fire #15 for attempt <= 3', () => {
+ const activity = makeActivity({
+ attempt: 2,
+ retryPolicy: { maximumAttempts: 10 },
+ });
+ expect(detectActivityErrors([activity]).some((e) => e.id === 15)).toBe(
+ false,
+ );
+ });
+
+ it('detects #16: startToCloseTimeout <= 1s', () => {
+ const activity = makeActivity({ startToCloseTimeout: '1 second' });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 16)).toBe(true);
+ });
+
+ it('detects #17: scheduleToClose set, no startToClose', () => {
+ const activity = makeActivity({
+ scheduleToCloseTimeout: '1 hour',
+ startToCloseTimeout: '',
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 17)).toBe(true);
+ });
+
+ it('detects #18: scheduleToClose/startToClose ratio < 2', () => {
+ const activity = makeActivity({
+ scheduleToCloseTimeout: '15 seconds',
+ startToCloseTimeout: '10 seconds',
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 18)).toBe(true);
+ });
+
+ it('does not fire #18 for ratio >= 2', () => {
+ const activity = makeActivity({
+ scheduleToCloseTimeout: '30 seconds',
+ startToCloseTimeout: '10 seconds',
+ });
+ expect(detectActivityErrors([activity]).some((e) => e.id === 18)).toBe(
+ false,
+ );
+ });
+
+ it('detects #19: schedule-to-close exceeds workflow run timeout', () => {
+ const activity = makeActivity({
+ scheduleToCloseTimeout: '2 hours',
+ retryPolicy: { maximumAttempts: 5 },
+ });
+ const workflowRunTimeout = 3600; // 1 hour
+ const errors = detectActivityErrors([activity], workflowRunTimeout);
+ expect(errors.some((e) => e.id === 19)).toBe(true);
+ });
+
+ it('does not fire #19 when schedule-to-close <= run timeout', () => {
+ const activity = makeActivity({
+ scheduleToCloseTimeout: '30 minutes',
+ retryPolicy: { maximumAttempts: 5 },
+ });
+ const workflowRunTimeout = 3600;
+ expect(
+ detectActivityErrors([activity], workflowRunTimeout).some(
+ (e) => e.id === 19,
+ ),
+ ).toBe(false);
+ });
+
+ it('does not fire #19 when run timeout is 0 (not set)', () => {
+ const activity = makeActivity({
+ scheduleToCloseTimeout: '2 hours',
+ retryPolicy: { maximumAttempts: 5 },
+ });
+ expect(detectActivityErrors([activity], 0).some((e) => e.id === 19)).toBe(
+ false,
+ );
+ });
+
+ it('detects #20: scheduleToStartTimeout set', () => {
+ const activity = makeActivity({ scheduleToStartTimeout: '5 seconds' });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 20)).toBe(true);
+ });
+
+ it('detects #21: heartbeat >= startToClose', () => {
+ const activity = makeActivity({
+ heartbeatTimeout: '10 seconds',
+ startToCloseTimeout: '10 seconds',
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 21)).toBe(true);
+ });
+
+ it('detects #22: heartbeat > 80% of startToClose (but < 100%)', () => {
+ const activity = makeActivity({
+ heartbeatTimeout: '9 seconds',
+ startToCloseTimeout: '10 seconds',
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 22)).toBe(true);
+ expect(errors.some((e) => e.id === 21)).toBe(false);
+ });
+
+ it('detects #23: heartbeat timeout set but no heartbeats detected', () => {
+ const activity = makeActivity({
+ heartbeatTimeout: '30 seconds',
+ startToCloseTimeout: '5 minutes',
+ });
+ const errors = detectActivityErrors([activity]);
+ expect(errors.some((e) => e.id === 23)).toBe(true);
+ });
+
+ it('does not fire #23 when lastHeartbeatTime exists', () => {
+ const activity = makeActivity({
+ heartbeatTimeout: '30 seconds',
+ startToCloseTimeout: '5 minutes',
+ lastHeartbeatTime: '2024-01-01T00:00:10Z',
+ });
+ expect(detectActivityErrors([activity]).some((e) => e.id === 23)).toBe(
+ false,
+ );
+ });
+
+ it('does not fire #23 when no heartbeat timeout', () => {
+ const activity = makeActivity({
+ startToCloseTimeout: '5 minutes',
+ });
+ expect(detectActivityErrors([activity]).some((e) => e.id === 23)).toBe(
+ false,
+ );
+ });
+
+ it('deduplicates across multiple activities', () => {
+ const a1 = makeActivity({ startToCloseTimeout: '1 second' });
+ const a2 = makeActivity({ startToCloseTimeout: '500 milliseconds' });
+ const errors = detectActivityErrors([a1, a2]);
+ const id16Count = errors.filter((e) => e.id === 16).length;
+ expect(id16Count).toBe(1);
+ });
+});
+
+describe('detectFirstEventErrors', () => {
+ it('returns empty for undefined firstEvent', () => {
+ expect(detectFirstEventErrors(undefined)).toHaveLength(0);
+ });
+
+ it('detects #2: execution timeout <= run timeout', () => {
+ const event = makeFirstEvent({
+ workflowExecutionTimeout: '10 minutes',
+ workflowRunTimeout: '20 minutes',
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 2)).toBe(true);
+ });
+
+ it('does not fire #2 when execution > run timeout', () => {
+ const event = makeFirstEvent({
+ workflowExecutionTimeout: '2 hours',
+ workflowRunTimeout: '1 hour',
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 2)).toBe(false);
+ });
+
+ it('does not fire #2 when either timeout is 0', () => {
+ const event = makeFirstEvent({
+ workflowExecutionTimeout: '0s',
+ workflowRunTimeout: '1 hour',
+ });
+ expect(detectFirstEventErrors(event).some((e) => e.id === 2)).toBe(false);
+ });
+
+ it('detects #10: retry policy defined', () => {
+ const event = makeFirstEvent({
+ retryPolicy: {
+ initialInterval: '1s',
+ backoffCoefficient: 2,
+ maximumInterval: '100s',
+ maximumAttempts: 0,
+ },
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 10)).toBe(true);
+ });
+
+ it('does not fire #10 when retryPolicy is null', () => {
+ const event = makeFirstEvent({ retryPolicy: null });
+ expect(detectFirstEventErrors(event).some((e) => e.id === 10)).toBe(false);
+ });
+
+ it('does not fire #10 when retryPolicy is empty object', () => {
+ const event = makeFirstEvent({ retryPolicy: {} });
+ expect(detectFirstEventErrors(event).some((e) => e.id === 10)).toBe(false);
+ });
+
+ it('detects #11: workflow is being retried (attempt >= 2)', () => {
+ const event = makeFirstEvent({
+ attempt: 2,
+ retryPolicy: {
+ initialInterval: '1s',
+ maximumAttempts: 5,
+ },
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 11)).toBe(true);
+ });
+
+ it('does not fire #11 for first attempt', () => {
+ const event = makeFirstEvent({
+ attempt: 1,
+ retryPolicy: {
+ initialInterval: '1s',
+ maximumAttempts: 5,
+ },
+ });
+ expect(detectFirstEventErrors(event).some((e) => e.id === 11)).toBe(false);
+ });
+
+ it('does not fire #11 when attempt is 0 or undefined', () => {
+ const event = makeFirstEvent({});
+ expect(detectFirstEventErrors(event).some((e) => e.id === 11)).toBe(false);
+ });
+
+ it('detects #3: very short run timeout from first event', () => {
+ const event = makeFirstEvent({
+ workflowRunTimeout: '1 minute',
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 3)).toBe(true);
+ });
+
+ it('does not fire #3 for run timeout >= 120s', () => {
+ const event = makeFirstEvent({
+ workflowRunTimeout: '5 minutes',
+ });
+ expect(detectFirstEventErrors(event).some((e) => e.id === 3)).toBe(false);
+ });
+
+ it('detects #32: multiple input payloads', () => {
+ const event = makeFirstEvent({
+ input: ['arg1', 'arg2'],
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 32)).toBe(true);
+ });
+
+ it('does not fire #32 for single payload', () => {
+ const event = makeFirstEvent({
+ input: ['arg1'],
+ });
+ expect(detectFirstEventErrors(event).some((e) => e.id === 32)).toBe(false);
+ });
+
+ it('detects #33: TERMINATE_IF_RUNNING reuse policy', () => {
+ const event = makeFirstEvent({
+ workflowIdReusePolicy: 'WORKFLOW_ID_REUSE_POLICY_TERMINATE_IF_RUNNING',
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 33)).toBe(true);
+ });
+
+ it('detects #34: REJECT_DUPLICATE reuse policy', () => {
+ const event = makeFirstEvent({
+ workflowIdReusePolicy: 'WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE',
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 34)).toBe(true);
+ });
+
+ it('detects #34: ALLOW_DUPLICATE_FAILED_ONLY reuse policy', () => {
+ const event = makeFirstEvent({
+ workflowIdReusePolicy:
+ 'WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE_FAILED_ONLY',
+ });
+ const errors = detectFirstEventErrors(event);
+ expect(errors.some((e) => e.id === 34)).toBe(true);
+ });
+});
+
+describe('detectEventHistoryErrors', () => {
+ it('returns empty for empty events', () => {
+ const workflow = makeWorkflow();
+ expect(detectEventHistoryErrors(workflow, [])).toHaveLength(0);
+ });
+
+ it('detects #8: CAN cost — low activity count with CAN', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ historyEvents: '30',
+ });
+ const events = [
+ makeEvent('ActivityTaskScheduled'),
+ makeEvent('ActivityTaskStarted'),
+ makeEvent('ActivityTaskCompleted'),
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 8)).toBe(true);
+ });
+
+ it('does not fire #8 for many activities', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ historyEvents: '30',
+ });
+ const events = [
+ makeEvent('ActivityTaskScheduled'),
+ makeEvent('ActivityTaskScheduled'),
+ makeEvent('ActivityTaskScheduled'),
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 8)).toBe(false);
+ });
+
+ it('does not fire #8 for non-CAN workflows', () => {
+ const workflow = makeWorkflow({
+ status: 'Completed',
+ historyEvents: '30',
+ });
+ const events = [makeEvent('ActivityTaskScheduled')];
+ expect(
+ detectEventHistoryErrors(workflow, events).some((e) => e.id === 8),
+ ).toBe(false);
+ });
+
+ it('does not fire #8 for CAN with high event count', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ historyEvents: '500',
+ });
+ const events = [makeEvent('ActivityTaskScheduled')];
+ expect(
+ detectEventHistoryErrors(workflow, events).some((e) => e.id === 8),
+ ).toBe(false);
+ });
+
+ it('detects #12: child workflow with retry policy', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ {
+ eventType: 'StartChildWorkflowExecutionInitiated',
+ startChildWorkflowExecutionInitiatedEventAttributes: {
+ retryPolicy: { initialInterval: '1s', maximumAttempts: 3 },
+ },
+ attributes: {
+ type: 'startChildWorkflowExecutionInitiatedEventAttributes',
+ },
+ } as unknown as WorkflowEvent,
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 12)).toBe(true);
+ });
+
+ it('does not fire #12 for child workflow without retry policy', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ {
+ eventType: 'StartChildWorkflowExecutionInitiated',
+ startChildWorkflowExecutionInitiatedEventAttributes: {
+ retryPolicy: null,
+ },
+ attributes: {
+ type: 'startChildWorkflowExecutionInitiatedEventAttributes',
+ },
+ } as unknown as WorkflowEvent,
+ ];
+ expect(
+ detectEventHistoryErrors(workflow, events).some((e) => e.id === 12),
+ ).toBe(false);
+ });
+
+ it('detects #26: LA extending workflow task (3+ consecutive cycles)', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ // Cycle 1
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ // Cycle 2
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ // Cycle 3
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 26)).toBe(true);
+ });
+
+ it('does not fire #26 for fewer than 3 consecutive cycles', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 26)).toBe(false);
+ });
+
+ it('detects #27: local activities not batched', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ // WFT 1 with 1 LA
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ // WFT 2 with 1 LA
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ // WFT 3 with 1 LA
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ // WFT 4 with 1 LA
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 27)).toBe(true);
+ });
+
+ it('does not fire #27 when LAs are batched', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ makeEvent('WorkflowTaskScheduled'),
+ makeEvent('WorkflowTaskStarted'),
+ makeLocalActivityMarker(),
+ makeLocalActivityMarker(),
+ makeLocalActivityMarker(),
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowTaskCompleted'),
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 27)).toBe(false);
+ });
+
+ it('detects #29: local activities with signals', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ makeLocalActivityMarker(),
+ makeEvent('WorkflowExecutionSignaled'),
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 29)).toBe(true);
+ });
+
+ it('does not fire #29 without signals', () => {
+ const workflow = makeWorkflow();
+ const events = [makeLocalActivityMarker()];
+ expect(
+ detectEventHistoryErrors(workflow, events).some((e) => e.id === 29),
+ ).toBe(false);
+ });
+
+ it('does not fire #29 without local activities', () => {
+ const workflow = makeWorkflow();
+ const events = [makeEvent('WorkflowExecutionSignaled')];
+ expect(
+ detectEventHistoryErrors(workflow, events).some((e) => e.id === 29),
+ ).toBe(false);
+ });
+
+ it('detects #30: local activities with WFT failure', () => {
+ const workflow = makeWorkflow();
+ const events = [
+ makeLocalActivityMarker(),
+ {
+ eventType: 'WorkflowTaskFailed',
+ workflowTaskFailedEventAttributes: {
+ failure: { message: 'timeout' },
+ },
+ attributes: { type: 'workflowTaskFailedEventAttributes' },
+ } as unknown as WorkflowEvent,
+ ];
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 30)).toBe(true);
+ });
+
+ it('does not fire #30 without WFT failures', () => {
+ const workflow = makeWorkflow();
+ const events = [makeLocalActivityMarker()];
+ expect(
+ detectEventHistoryErrors(workflow, events).some((e) => e.id === 30),
+ ).toBe(false);
+ });
+
+ it('detects #28: many LA markers with high event count (excessive retries)', () => {
+ const workflow = makeWorkflow({ historyEvents: '3000' });
+ const events: WorkflowEvent[] = [];
+ for (let i = 0; i < 25; i++) {
+ events.push(makeLocalActivityMarker());
+ }
+ const errors = detectEventHistoryErrors(workflow, events);
+ expect(errors.some((e) => e.id === 28)).toBe(true);
+ });
+
+ it('does not fire #28 for low LA marker count', () => {
+ const workflow = makeWorkflow({ historyEvents: '3000' });
+ const events = [makeLocalActivityMarker(), makeLocalActivityMarker()];
+ expect(
+ detectEventHistoryErrors(workflow, events).some((e) => e.id === 28),
+ ).toBe(false);
+ });
+});
+
+describe('getApplicableCommonErrors', () => {
+ it('returns empty for healthy workflow', () => {
+ const workflow = makeWorkflow();
+ expect(getApplicableCommonErrors(workflow, undefined)).toHaveLength(0);
+ });
+
+ it('sorts by severity: errors first, then warnings, then info', () => {
+ const workflow = makeWorkflow({
+ workflowExecutionTimeout: '60s',
+ defaultWorkflowTaskTimeout: '30s',
+ });
+ const event = makeFirstEvent({
+ retryPolicy: { maximumAttempts: 0 },
+ });
+ const errors = getApplicableCommonErrors(workflow, event);
+ for (let i = 1; i < errors.length; i++) {
+ const prevOrder =
+ errors[i - 1].severity === 'error'
+ ? 0
+ : errors[i - 1].severity === 'warning'
+ ? 1
+ : 2;
+ const currOrder =
+ errors[i].severity === 'error'
+ ? 0
+ : errors[i].severity === 'warning'
+ ? 1
+ : 2;
+ expect(prevOrder).toBeLessThanOrEqual(currOrder);
+ }
+ });
+
+ it('deduplicates errors from multiple detectors', () => {
+ const workflow = makeWorkflow({ workflowExecutionTimeout: '3600s' });
+ const errors = getApplicableCommonErrors(workflow, undefined);
+ const ids = errors.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('returns null-safe for null workflow', () => {
+ expect(getApplicableCommonErrors(null, undefined)).toHaveLength(0);
+ });
+
+ it('includes event history errors when eventHistory is provided', () => {
+ const workflow = makeWorkflow({
+ status: 'ContinuedAsNew',
+ historyEvents: '30',
+ });
+ const events = [makeEvent('ActivityTaskScheduled')];
+ const errors = getApplicableCommonErrors(
+ workflow,
+ undefined,
+ events as WorkflowEvent[],
+ );
+ expect(errors.some((e) => e.id === 8)).toBe(true);
+ });
+
+ it('passes workflowRunTimeout from first event to activity detection', () => {
+ const workflow = makeWorkflow({
+ pendingActivities: [
+ makeActivity({
+ scheduleToCloseTimeout: '2 hours',
+ retryPolicy: { maximumAttempts: 5 },
+ }),
+ ],
+ });
+ const event = makeFirstEvent({
+ workflowRunTimeout: '1 hour',
+ });
+ const errors = getApplicableCommonErrors(workflow, event);
+ expect(errors.some((e) => e.id === 19)).toBe(true);
+ });
+});
diff --git a/src/lib/utilities/common-error-detection.ts b/src/lib/utilities/common-error-detection.ts
new file mode 100644
index 0000000000..41cf4d613b
--- /dev/null
+++ b/src/lib/utilities/common-error-detection.ts
@@ -0,0 +1,494 @@
+import { getCommonErrorById } from '$lib/components/common-errors/common-errors-data';
+import type { CommonError } from '$lib/types/common-errors';
+import type { PendingActivity, WorkflowEvent } from '$lib/types/events';
+import type { WorkflowExecution } from '$lib/types/workflows';
+import {
+ isActivityTaskScheduledEvent,
+ isLocalActivityMarkerEvent,
+ isStartChildWorkflowExecutionInitiatedEvent,
+ isWorkflowExecutionSignaledEvent,
+ isWorkflowExecutionStartedEvent,
+ isWorkflowTaskCompletedEvent,
+ isWorkflowTaskFailedEvent,
+ isWorkflowTaskScheduledEvent,
+ isWorkflowTaskStartedEvent,
+ isWorkflowTaskTimedOutEvent,
+} from '$lib/utilities/is-event-type';
+
+const SEVERITY_ORDER: Record = {
+ error: 0,
+ warning: 1,
+ info: 2,
+};
+
+const SENSITIVE_MEMO_PATTERNS =
+ /(^|[^a-z])(ssn|social_security|password|passwd|secret|credit_card|creditcard|card_number|cardnumber|api_key|apikey|private_key|privatekey|access_token|accesstoken)([^a-z]|$)/i;
+
+export function durationToSeconds(duration: unknown): number {
+ if (!duration) return 0;
+
+ if (
+ typeof duration === 'object' &&
+ duration !== null &&
+ 'seconds' in duration
+ ) {
+ const seconds = Number((duration as { seconds: string | number }).seconds);
+ const nanos = Number((duration as { nanos?: number }).nanos);
+ return (isNaN(seconds) ? 0 : seconds) + (isNaN(nanos) ? 0 : nanos / 1e9);
+ }
+
+ if (typeof duration !== 'string') return 0;
+
+ const nsMatch = duration.match(/^(\d+\.?\d*)s$/);
+ if (nsMatch) return parseFloat(nsMatch[1]);
+
+ let total = 0;
+ const parts = duration.split(', ');
+ for (const part of parts) {
+ const match = part.match(/^(\d+\.?\d*)\s+(\w+)$/);
+ if (!match) continue;
+ const value = parseFloat(match[1]);
+ const unit = match[2].toLowerCase();
+ if (unit.startsWith('day')) total += value * 86400;
+ else if (unit.startsWith('hour')) total += value * 3600;
+ else if (unit.startsWith('minute')) total += value * 60;
+ else if (unit.startsWith('second')) total += value;
+ else if (unit.startsWith('millisecond')) total += value / 1000;
+ }
+ return total;
+}
+
+export function detectWorkflowErrors(
+ workflow: WorkflowExecution,
+): CommonError[] {
+ const errors: CommonError[] = [];
+
+ const addError = (id: number) => {
+ const err = getCommonErrorById(id);
+ if (err) errors.push(err);
+ };
+
+ const execTimeout = durationToSeconds(workflow.workflowExecutionTimeout);
+ if (execTimeout > 0) {
+ addError(1);
+ if (execTimeout < 120) {
+ addError(3);
+ }
+ }
+
+ const taskTimeout = durationToSeconds(workflow.defaultWorkflowTaskTimeout);
+ if (taskTimeout > 0 && taskTimeout !== 10) {
+ addError(4);
+ if (taskTimeout > 10) {
+ addError(5);
+ } else {
+ addError(6);
+ }
+ }
+
+ if (
+ workflow.status === 'ContinuedAsNew' &&
+ workflow.startTime &&
+ workflow.endTime
+ ) {
+ const durationMs =
+ new Date(workflow.endTime).getTime() -
+ new Date(workflow.startTime).getTime();
+ if (durationMs < 2000) {
+ addError(7);
+ }
+ }
+
+ // #9: CAN with low event count suggests fixed schedule instead of suggest_continue_as_new
+ const historyEvents = parseInt(workflow.historyEvents, 10);
+ if (
+ workflow.status === 'ContinuedAsNew' &&
+ historyEvents > 0 &&
+ historyEvents < 2000
+ ) {
+ addError(9);
+ }
+
+ const startDelay = durationToSeconds(workflow.startDelay);
+ if (startDelay > 0) {
+ if (startDelay > 86400) {
+ addError(24);
+ }
+ if (startDelay < 1) {
+ addError(25);
+ }
+ }
+
+ if (historyEvents > 10000) {
+ addError(31);
+ }
+
+ // #35: Sensitive data in memo field names
+ const memoFields = workflow.memo?.fields;
+ if (memoFields && typeof memoFields === 'object') {
+ const keys = Object.keys(memoFields);
+ if (keys.some((key) => SENSITIVE_MEMO_PATTERNS.test(key))) {
+ addError(35);
+ }
+ }
+
+ return errors;
+}
+
+export function detectActivityErrors(
+ pendingActivities: PendingActivity[],
+ workflowRunTimeout = 0,
+): CommonError[] {
+ if (!pendingActivities?.length) return [];
+
+ const errorIds = new Set();
+
+ for (const activity of pendingActivities) {
+ const act = activity as Record;
+ const retryPolicy = act.retryPolicy as
+ | { maximumAttempts?: number | string }
+ | null
+ | undefined;
+
+ if (retryPolicy && Number(retryPolicy.maximumAttempts) === 1) {
+ errorIds.add(14);
+ }
+
+ // #13: No explicit retry policy or maximumAttempts is 0 (unlimited)
+ if (
+ !retryPolicy ||
+ !retryPolicy.maximumAttempts ||
+ Number(retryPolicy.maximumAttempts) === 0
+ ) {
+ errorIds.add(13);
+ }
+
+ // #15: Activity is retrying — suggest non-retryable error types
+ const attempt = Number(act.attempt);
+ if (attempt > 3) {
+ errorIds.add(15);
+ }
+
+ const startToClose = durationToSeconds(act.startToCloseTimeout);
+ const scheduleToClose = durationToSeconds(act.scheduleToCloseTimeout);
+ const scheduleToStart = durationToSeconds(act.scheduleToStartTimeout);
+ const heartbeat = durationToSeconds(act.heartbeatTimeout);
+
+ if (startToClose > 0 && startToClose <= 1) {
+ errorIds.add(16);
+ }
+
+ if (scheduleToClose > 0 && startToClose === 0) {
+ errorIds.add(17);
+ }
+
+ if (scheduleToClose > 0 && startToClose > 0) {
+ const ratio = scheduleToClose / startToClose;
+ if (ratio < 2) {
+ errorIds.add(18);
+ }
+ }
+
+ // #19: Schedule-to-Close exceeds Workflow Run Timeout
+ if (
+ scheduleToClose > 0 &&
+ workflowRunTimeout > 0 &&
+ scheduleToClose > workflowRunTimeout
+ ) {
+ errorIds.add(19);
+ }
+
+ if (scheduleToStart > 0) {
+ errorIds.add(20);
+ }
+
+ if (heartbeat > 0 && startToClose > 0) {
+ if (heartbeat >= startToClose) {
+ errorIds.add(21);
+ } else if (heartbeat > startToClose * 0.8) {
+ errorIds.add(22);
+ }
+ }
+
+ // #23: Heartbeat timeout set but no heartbeats detected
+ if (heartbeat > 0 && !act.lastHeartbeatTime) {
+ errorIds.add(23);
+ }
+ }
+
+ return [...errorIds].map(getCommonErrorById).filter(Boolean);
+}
+
+export function detectFirstEventErrors(
+ firstEvent: WorkflowEvent | undefined,
+): CommonError[] {
+ if (!firstEvent || !isWorkflowExecutionStartedEvent(firstEvent)) return [];
+
+ const errors: CommonError[] = [];
+ const attrs = firstEvent.workflowExecutionStartedEventAttributes as
+ | Record
+ | undefined;
+ if (!attrs) return [];
+
+ const addError = (id: number) => {
+ const err = getCommonErrorById(id);
+ if (err) errors.push(err);
+ };
+
+ const execTimeout = durationToSeconds(attrs.workflowExecutionTimeout);
+ const runTimeout = durationToSeconds(attrs.workflowRunTimeout);
+ if (execTimeout > 0 && runTimeout > 0 && execTimeout <= runTimeout) {
+ addError(2);
+ }
+
+ if (runTimeout > 0 && runTimeout < 120) {
+ addError(3);
+ }
+
+ const retryPolicy = attrs.retryPolicy;
+ if (
+ retryPolicy &&
+ typeof retryPolicy === 'object' &&
+ Object.keys(retryPolicy).length > 0
+ ) {
+ addError(10);
+ }
+
+ // #11: Workflow is actually being retried (attempt >= 2 means at least one retry)
+ const attempt = Number(attrs.attempt);
+ if (attempt >= 2) {
+ addError(11);
+ }
+
+ const input = attrs.input;
+ const payloads = Array.isArray(input)
+ ? input
+ : (input as { payloads?: unknown[] })?.payloads;
+ if (Array.isArray(payloads) && payloads.length > 1) {
+ addError(32);
+ }
+
+ const reusePolicy = String(attrs.workflowIdReusePolicy ?? '');
+ if (reusePolicy.includes('TERMINATE_IF_RUNNING')) {
+ addError(33);
+ }
+ if (
+ reusePolicy.includes('REJECT_DUPLICATE') ||
+ reusePolicy.includes('ALLOW_DUPLICATE_FAILED_ONLY')
+ ) {
+ addError(34);
+ }
+
+ return errors;
+}
+
+export function detectEventHistoryErrors(
+ workflow: WorkflowExecution,
+ events: WorkflowEvent[],
+): CommonError[] {
+ if (!events?.length) return [];
+
+ const errorIds = new Set();
+
+ let activityScheduledCount = 0;
+ let hasLocalActivityMarker = false;
+ let hasSignalEvent = false;
+ let hasWftFailure = false;
+ let hasChildWfWithRetryPolicy = false;
+
+ // Local activity batching analysis (#26, #27)
+ let currentWftLaCount = 0;
+ let consecutiveWftOnlyCycles = 0;
+ let totalLaMarkers = 0;
+ let wftWithLaCount = 0;
+ let inWft = false;
+ let currentWftHasNonLaCommand = false;
+
+ for (const event of events) {
+ // Count activity scheduled events (#8)
+ if (isActivityTaskScheduledEvent(event)) {
+ activityScheduledCount++;
+ }
+
+ // Detect local activity markers (#26, #27, #28, #29, #30)
+ if (isLocalActivityMarkerEvent(event)) {
+ hasLocalActivityMarker = true;
+ totalLaMarkers++;
+ if (inWft) {
+ currentWftLaCount++;
+ }
+ }
+
+ // Detect signal events (#29)
+ if (isWorkflowExecutionSignaledEvent(event)) {
+ hasSignalEvent = true;
+ }
+
+ // Detect WFT failures (#30)
+ if (
+ isWorkflowTaskFailedEvent(event) ||
+ isWorkflowTaskTimedOutEvent(event)
+ ) {
+ hasWftFailure = true;
+ }
+
+ // Detect child workflow retry policies (#12)
+ if (isStartChildWorkflowExecutionInitiatedEvent(event)) {
+ const attrs = (event as Record)
+ .startChildWorkflowExecutionInitiatedEventAttributes as
+ | Record
+ | undefined;
+ if (
+ attrs?.retryPolicy &&
+ typeof attrs.retryPolicy === 'object' &&
+ Object.keys(attrs.retryPolicy as object).length > 0
+ ) {
+ hasChildWfWithRetryPolicy = true;
+ }
+ }
+
+ // WFT cycle tracking for LA pattern detection (#26, #27)
+ if (isWorkflowTaskScheduledEvent(event)) {
+ inWft = true;
+ currentWftLaCount = 0;
+ currentWftHasNonLaCommand = false;
+ }
+
+ if (isWorkflowTaskStartedEvent(event)) {
+ // still in WFT
+ }
+
+ if (isWorkflowTaskCompletedEvent(event)) {
+ if (inWft) {
+ if (currentWftLaCount > 0) {
+ wftWithLaCount++;
+ if (!currentWftHasNonLaCommand) {
+ consecutiveWftOnlyCycles++;
+ } else {
+ consecutiveWftOnlyCycles = 0;
+ }
+ } else {
+ consecutiveWftOnlyCycles = 0;
+ }
+ }
+ inWft = false;
+ }
+
+ // Track non-LA commands within a WFT (for #26)
+ if (
+ inWft &&
+ !isWorkflowTaskScheduledEvent(event) &&
+ !isWorkflowTaskStartedEvent(event) &&
+ !isWorkflowTaskCompletedEvent(event) &&
+ !isLocalActivityMarkerEvent(event)
+ ) {
+ if (
+ isActivityTaskScheduledEvent(event) ||
+ isStartChildWorkflowExecutionInitiatedEvent(event)
+ ) {
+ currentWftHasNonLaCommand = true;
+ }
+ }
+ }
+
+ // #8: CAN cost — iterating one item at a time via CAN
+ if (
+ workflow.status === 'ContinuedAsNew' &&
+ activityScheduledCount <= 2 &&
+ parseInt(workflow.historyEvents, 10) < 50
+ ) {
+ errorIds.add(8);
+ }
+
+ // #12: Child workflow with retry policy
+ if (hasChildWfWithRetryPolicy) {
+ errorIds.add(12);
+ }
+
+ // #26: Local activity extending workflow task (3+ consecutive WFT-only cycles)
+ if (consecutiveWftOnlyCycles >= 3) {
+ errorIds.add(26);
+ }
+
+ // #27: Local activities not batched
+ if (totalLaMarkers > 3 && wftWithLaCount > 0) {
+ const avgLaPerWft = totalLaMarkers / wftWithLaCount;
+ if (avgLaPerWft < 1.5) {
+ errorIds.add(27);
+ }
+ }
+
+ // #28: Local activity with unlimited retries (best-effort from marker data)
+ // LA markers may contain retry info in details; check if any LA markers exist
+ // without bounded retry policies. Since marker events don't reliably expose
+ // retry policies, we detect this when we see LA markers and the workflow
+ // has high event count relative to LA count (suggesting excessive retries).
+ if (
+ totalLaMarkers > 0 &&
+ parseInt(workflow.historyEvents, 10) > 2000 &&
+ totalLaMarkers > 20
+ ) {
+ errorIds.add(28);
+ }
+
+ // #29: Local activities may delay signal processing
+ if (hasLocalActivityMarker && hasSignalEvent) {
+ errorIds.add(29);
+ }
+
+ // #30: Local activities fully retry on WFT failure
+ if (hasLocalActivityMarker && hasWftFailure) {
+ errorIds.add(30);
+ }
+
+ return [...errorIds].map(getCommonErrorById).filter(Boolean);
+}
+
+export function getApplicableCommonErrors(
+ workflow: WorkflowExecution,
+ firstEvent: WorkflowEvent | undefined,
+ eventHistory?: WorkflowEvent[],
+): CommonError[] {
+ if (!workflow) return [];
+
+ // Extract workflowRunTimeout from first event for cross-reference checks
+ let workflowRunTimeout = 0;
+ if (firstEvent && isWorkflowExecutionStartedEvent(firstEvent)) {
+ const attrs = firstEvent.workflowExecutionStartedEventAttributes as
+ | Record
+ | undefined;
+ if (attrs) {
+ workflowRunTimeout = durationToSeconds(attrs.workflowRunTimeout);
+ }
+ }
+
+ const workflowErrors = detectWorkflowErrors(workflow);
+ const activityErrors = detectActivityErrors(
+ workflow.pendingActivities,
+ workflowRunTimeout,
+ );
+ const firstEventErrors = detectFirstEventErrors(firstEvent);
+ const eventHistoryErrors = eventHistory?.length
+ ? detectEventHistoryErrors(workflow, eventHistory)
+ : [];
+
+ const seen = new Set();
+ const all: CommonError[] = [];
+
+ for (const error of [
+ ...workflowErrors,
+ ...activityErrors,
+ ...firstEventErrors,
+ ...eventHistoryErrors,
+ ]) {
+ if (!seen.has(error.id)) {
+ seen.add(error.id);
+ all.push(error);
+ }
+ }
+
+ return all.sort(
+ (a, b) =>
+ (SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2),
+ );
+}
diff --git a/src/lib/utilities/route-for-base-path.test.ts b/src/lib/utilities/route-for-base-path.test.ts
index 627fbf8fd8..031d0de923 100644
--- a/src/lib/utilities/route-for-base-path.test.ts
+++ b/src/lib/utilities/route-for-base-path.test.ts
@@ -10,6 +10,7 @@ import {
routeForBatchOperation,
routeForBatchOperations,
routeForCallStack,
+ routeForCommonErrors,
routeForEventHistory,
routeForEventHistoryEvent,
routeForEventHistoryImport,
@@ -188,6 +189,7 @@ describe('routeFor functions should resolve the base path exactly once', () => {
}),
],
['routeForLoginPage', () => routeForLoginPage('', false)],
+ ['routeForCommonErrors', () => routeForCommonErrors()],
];
it.each(cases)('%s should resolve the base path', (_name, fn) => {
diff --git a/src/lib/utilities/route-for.ts b/src/lib/utilities/route-for.ts
index ba491892b9..5cc40307d6 100644
--- a/src/lib/utilities/route-for.ts
+++ b/src/lib/utilities/route-for.ts
@@ -83,6 +83,10 @@ export const routeForNexus = (): ResolvedPathname => {
return resolve('/nexus', {});
};
+export const routeForCommonErrors = (): ResolvedPathname => {
+ return resolve('/common-errors', {});
+};
+
export const routeForNexusEndpoint = (id: string): ResolvedPathname => {
return resolve('/nexus/[id]', { id });
};
diff --git a/src/routes/(app)/common-errors/+page.svelte b/src/routes/(app)/common-errors/+page.svelte
new file mode 100644
index 0000000000..d1b1ca85c4
--- /dev/null
+++ b/src/routes/(app)/common-errors/+page.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+Common Errors
+
+
+ {
+ activeFilter = 'all';
+ }}>All ({COMMON_ERRORS.length})
+ {
+ activeFilter = 'error';
+ }}>Errors ({errorCount})
+ {
+ activeFilter = 'warning';
+ }}>Warnings ({warningCount})
+ {
+ activeFilter = 'info';
+ }}>Info ({infoCount})
+
+
+