diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx index 12a3adccf4d..32f9d5dd075 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx @@ -32,6 +32,8 @@ const EVENT_COLUMN_WIDTH_CLASS = { type EventColumnWidth = keyof typeof EVENT_COLUMN_WIDTH_CLASS +const ROW_CLASS = 'flex w-full items-center gap-3 px-3 py-2 text-left' + function ActivityLogRow({ entry, eventColumn, @@ -42,6 +44,39 @@ function ActivityLogRow({ const [expanded, setExpanded] = useState(false) const expandable = entry.details != null + const cells = ( + <> + + {entry.timestamp} + + + {entry.event} + + + {typeof entry.description === 'string' ? ( + + ) : ( + entry.description + )} + + + {typeof entry.actor === 'string' ? ( + + ) : ( + {entry.actor} + )} + {expandable && ( + + )} + + + ) + return (
- + {expandable ? ( + + ) : ( + // A row with nothing to expand is inert content, not a disabled control: + // browsers suppress pointer events over a disabled button AND its + // descendants, which would swallow the hover tooltips inside the cells. +
{cells}
+ )} {expandable && expanded && (
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',