diff --git a/apps/sim/background/fork-content-copy.ts b/apps/sim/background/fork-content-copy.ts
index 7391b78c2c4..8b721cecc62 100644
--- a/apps/sim/background/fork-content-copy.ts
+++ b/apps/sim/background/fork-content-copy.ts
@@ -11,9 +11,15 @@ import {
* non-transactional best-effort (per-row inserts with fresh ids), so a blind
* re-run would duplicate rows; a partial failure simply leaves the fork's content
* incomplete (the workflows themselves committed synchronously).
+ *
+ * Runs on `large-2x` (8 vCPU / 16 GB), matching `knowledge-connector-sync`: the
+ * copy materializes table rows, KB chunks and their embedding vectors, and file
+ * blobs in memory, and `maxAttempts: 1` means an OOM is unrecoverable — a
+ * half-copied fork with no retry.
*/
export const forkContentCopyTask = task({
id: 'fork-content-copy',
+ machine: 'large-2x',
retry: { maxAttempts: 1 },
queue: {
name: 'fork-content-copy',
diff --git a/apps/sim/background/knowledge-connector-sync.ts b/apps/sim/background/knowledge-connector-sync.ts
index f92c440a146..ee17426f2bc 100644
--- a/apps/sim/background/knowledge-connector-sync.ts
+++ b/apps/sim/background/knowledge-connector-sync.ts
@@ -40,7 +40,7 @@ export async function executeConnectorSyncJob(payload: unknown) {
export const knowledgeConnectorSync = task({
id: 'knowledge-connector-sync',
maxDuration: 1800,
- machine: 'large-1x',
+ machine: 'large-2x',
retry: {
maxAttempts: 3,
factor: 2,
diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts
index da02f0ebfaa..d7f0aae3643 100644
--- a/apps/sim/background/schedule-execution.ts
+++ b/apps/sim/background/schedule-execution.ts
@@ -1457,7 +1457,7 @@ export async function executeJobInline(payload: JobExecutionPayload) {
export const scheduleExecutionTaskOptions = {
id: 'schedule-execution',
- machine: 'medium-1x' as const,
+ machine: 'medium-2x' as const,
retry: {
maxAttempts: 1,
},
diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts
index 1be362811d2..cce7d55dd3e 100644
--- a/apps/sim/background/workflow-execution.ts
+++ b/apps/sim/background/workflow-execution.ts
@@ -230,7 +230,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
export const workflowExecutionTask = task({
id: 'workflow-execution',
- machine: 'medium-1x',
+ machine: 'medium-2x',
queue: {
concurrencyLimit: WORKFLOW_EXECUTION_CONCURRENCY_LIMIT,
},
diff --git a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx
new file mode 100644
index 00000000000..af633e4e6cd
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx
@@ -0,0 +1,171 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
+
+const { mockUseWorkspaceBackgroundWork } = vi.hoisted(() => ({
+ mockUseWorkspaceBackgroundWork: vi.fn(),
+}))
+
+vi.mock('@/ee/workspace-forking/hooks/background-work', () => ({
+ useWorkspaceBackgroundWork: mockUseWorkspaceBackgroundWork,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
+ SettingsEmptyState: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}))
+
+vi.mock('@/app/workspace/[workspaceId]/components', () => ({
+ FloatingOverflowText: ({ label, className }: { label: string; className?: string }) => (
+
{label}
+ ),
+}))
+
+import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel'
+
+const WORKSPACE_ID = 'ws-1'
+const PARTNER_ID = 'ws-parent'
+const WORKSPACE_NAMES = new Map([[PARTNER_ID, 'another workspace']])
+
+function makeJob(overrides: Partial
= {}): BackgroundWorkItem {
+ return {
+ id: 'job-1',
+ workspaceId: PARTNER_ID,
+ workflowId: null,
+ kind: 'fork_content_copy',
+ status: 'completed',
+ message: null,
+ error: null,
+ metadata: { childWorkspaceId: WORKSPACE_ID, actorName: 'Brandon Tarr' },
+ startedAt: '2026-07-28T15:58:00.000Z',
+ completedAt: null,
+ ...overrides,
+ } as BackgroundWorkItem
+}
+
+let container: HTMLDivElement
+let root: Root
+
+function renderJobs(jobs: BackgroundWorkItem[]) {
+ mockUseWorkspaceBackgroundWork.mockReturnValue({
+ data: { pages: [{ items: jobs, nextCursor: null }] },
+ isPending: false,
+ isError: false,
+ hasNextPage: false,
+ fetchNextPage: vi.fn(),
+ isFetchingNextPage: false,
+ })
+ act(() => {
+ root.render()
+ })
+}
+
+/** The Event-column badge for the single rendered row (`Badge`'s base class). */
+function badgeElement(): HTMLElement {
+ const badge = container.querySelector('.inline-flex')
+ if (!badge) throw new Error('badge not found')
+ return badge
+}
+
+/** Hover the badge the way React sees it — `onPointerEnter` is delegated from `pointerover`. */
+function hover(element: HTMLElement) {
+ act(() => {
+ element.dispatchEvent(
+ new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
+ )
+ })
+}
+
+function tooltipText(): string | null {
+ return document.body.querySelector('[role="tooltip"]')?.textContent ?? null
+}
+
+describe('ForkActivityPanel event badge tooltip', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('shows the failure reason when hovering a failed (red) badge', () => {
+ renderJobs([makeJob({ status: 'failed', error: 'Storage quota exceeded while copying files' })])
+
+ expect(tooltipText()).toBeNull()
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Storage quota exceeded while copying files')
+ })
+
+ it('falls back to a generic label when a failed row carries no error text', () => {
+ renderJobs([makeJob({ status: 'failed', error: null })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Failed')
+ })
+
+ it('truncates a very long failure reason', () => {
+ renderJobs([makeJob({ status: 'failed', error: 'x'.repeat(500) })])
+
+ hover(badgeElement())
+ const text = tooltipText() ?? ''
+ expect(text.endsWith('...')).toBe(true)
+ expect(text.length).toBeLessThan(260)
+ })
+
+ it('says "In progress" when hovering a processing (grey) badge', () => {
+ renderJobs([makeJob({ status: 'processing' })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('In progress')
+ })
+
+ it('says "Queued" when hovering a pending (grey) badge', () => {
+ renderJobs([makeJob({ status: 'pending' })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Queued')
+ })
+
+ it('shows nothing when hovering a completed (blue) badge', () => {
+ renderJobs([makeJob({ status: 'completed' })])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBeNull()
+ })
+
+ it('surfaces the partial-copy summary on a completed_with_warnings (amber) badge', () => {
+ renderJobs([
+ makeJob({
+ status: 'completed_with_warnings',
+ message: 'Copied 12 items; 3 could not be copied',
+ }),
+ ])
+
+ hover(badgeElement())
+ expect(tooltipText()).toBe('Copied 12 items; 3 could not be copied')
+ })
+
+ it('keeps a still-running row hoverable by not rendering it as a disabled button', () => {
+ renderJobs([makeJob({ status: 'processing' })])
+
+ // A processing row has no report yet, so it is not expandable. It must still
+ // be a plain row — a disabled button would swallow the badge's hover events.
+ expect(container.querySelector('button[disabled]')).toBeNull()
+ })
+
+ it('still renders an expandable row as a button', () => {
+ renderJobs([makeJob({ status: 'failed', error: 'boom' })])
+
+ const row = container.querySelector('button')
+ expect(row).not.toBeNull()
+ expect(row?.getAttribute('aria-expanded')).toBe('false')
+ })
+})
diff --git a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx
index 81faf69ef2c..d0d5d7f4ceb 100644
--- a/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx
+++ b/apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.tsx
@@ -1,9 +1,10 @@
'use client'
import { useCallback, useMemo } from 'react'
-import { Badge, Button } from '@sim/emcn'
+import { Badge, Button, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDateTime } from '@sim/utils/formatting'
+import { truncate } from '@sim/utils/string'
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
import {
ActivityLog,
@@ -14,6 +15,12 @@ import { useWorkspaceBackgroundWork } from '@/ee/workspace-forking/hooks/backgro
const logger = createLogger('ForkActivityPanel')
+/**
+ * Errors can carry a full driver message; the badge tooltip is a glance-level
+ * summary, so cap it. The untruncated text stays in the expanded detail box.
+ */
+const TOOLTIP_MAX_LENGTH = 240
+
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
@@ -122,6 +129,27 @@ function jobBadgeVariant(job: BackgroundWorkItem) {
}
}
+/**
+ * Hover text for the Event badge, explaining what its color means. Successful rows
+ * (the per-operation colors) return null — the color already says "done", and the
+ * breakdown is one click away in the expanded row — so only the states a reader
+ * can't act on from color alone get a tooltip.
+ */
+function jobStatusTooltip(job: BackgroundWorkItem): string | null {
+ switch (job.status) {
+ case 'pending':
+ return 'Queued'
+ case 'processing':
+ return 'In progress'
+ case 'failed':
+ return truncate(job.error ?? 'Failed', TOOLTIP_MAX_LENGTH)
+ case 'completed_with_warnings':
+ return truncate(job.message ?? 'Completed with warnings', TOOLTIP_MAX_LENGTH)
+ default:
+ return null
+ }
+}
+
/** Build a job's report (named groups + plain notes) from its metadata. */
function jobReport(job: BackgroundWorkItem): JobReport {
const m = job.metadata
@@ -239,13 +267,24 @@ function jobDetails(job: BackgroundWorkItem, report: JobReport) {
function toActivityEntry(job: BackgroundWorkItem, view: ActivityView): ActivityLogEntry {
const report = jobReport(job)
const hasDetails = report.groups.length > 0 || report.notes.length > 0 || Boolean(job.error)
+ const tooltip = jobStatusTooltip(job)
+ const badge = (
+
+ {jobEventLabel(job)}
+
+ )
return {
id: job.id,
timestamp: formatDateTime(new Date(job.startedAt)),
- event: (
-
- {jobEventLabel(job)}
-
+ event: tooltip ? (
+
+ {badge}
+
+ {tooltip}
+
+
+ ) : (
+ badge
),
description: jobTitle(job, view),
actor: job.metadata?.actorName || 'System',