Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ import {
ChevronUp,
Clipboard,
Search,
SquareArrowUpRight,
Workflow,
Wrench,
X,
} from '@sim/emcn/icons'
import { formatDuration } from '@sim/utils/formatting'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { createPortal } from 'react-dom'
Expand All @@ -59,8 +61,10 @@ import {
DELETED_WORKFLOW_LABEL,
formatDate,
getDisplayStatus,
resolveLogWorkflowId,
StatusBadge,
TriggerBadge,
workflowEditorPath,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { usePermissionConfig } from '@/hooks/use-permission-config'
Expand Down Expand Up @@ -317,6 +321,19 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
const isWorkflowExecutionLog =
(log.trigger === 'manual' && !!log.duration) || !!log.executionData?.traceSpans

/**
* The workflow this run belongs to, when it is still reachable. Null for Sim
* agent jobs and deleted workflows, which render their label as static text.
*/
const openableWorkflowId = resolveLogWorkflowId(log)

const workflowLabel =
log.trigger === 'mothership'
? log.jobTitle || 'Untitled Job'
: openableWorkflowId
? log.workflow?.name || 'Unknown'
: DELETED_WORKFLOW_LABEL

const hasCostInfo = !!(isWorkflowExecutionLog && log.cost)
const showWorkflowState =
isWorkflowExecutionLog &&
Expand Down Expand Up @@ -465,15 +482,31 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
{log.trigger === 'mothership' ? 'Job' : 'Workflow'}
</span>
<div className='flex min-w-0 items-center gap-1.5'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
{log.trigger === 'mothership'
? log.jobTitle || 'Untitled Job'
: log.workflow?.name ||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
</span>
</div>
{openableWorkflowId ? (
<Link
href={workflowEditorPath(workspaceId, openableWorkflowId)}
target='_blank'
rel='noopener noreferrer'
prefetch={false}
className='-mx-1.5 -my-0.5 group flex w-fit min-w-0 max-w-[calc(100%+0.75rem)] items-center gap-1.5 rounded-[5px] px-1.5 py-0.5 transition-colors hover-hover:bg-[var(--surface-active)] focus-visible:bg-[var(--surface-active)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]'
>
<span className='inline-grid size-[14px] shrink-0 place-items-center'>
<Workflow className='col-start-1 row-start-1 size-[14px] text-[var(--text-icon)] opacity-100 blur-0 transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
<SquareArrowUpRight className='col-start-1 row-start-1 size-[14px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-0 motion-reduce:transition-none' />
</span>
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm transition-colors group-hover:text-[var(--text-primary)] group-focus-visible:text-[var(--text-primary)]'>
{workflowLabel}
</span>
<span className='sr-only'>(opens in a new tab)</span>
</Link>
) : (
<div className='flex min-w-0 items-center gap-1.5'>
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
{workflowLabel}
</span>
</div>
)}
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
X,
} from '@sim/emcn'
import type { WorkflowLogSummary } from '@/lib/api/contracts/logs'
import { resolveLogWorkflowId } from '@/app/workspace/[workspaceId]/logs/utils'

interface LogRowContextMenuProps {
isOpen: boolean
Expand Down Expand Up @@ -58,6 +59,12 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
}: LogRowContextMenuProps) {
const hasExecutionId = Boolean(log?.executionId)
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
/**
* "Open Workflow" needs a navigable target, which is stricter than
* `hasWorkflow`: Sim agent jobs have no workflow of their own. Cancel/retry
* keep using `hasWorkflow` so their gating is unchanged.
*/
const hasOpenableWorkflow = Boolean(log && resolveLogWorkflowId(log))
const isCancellable =
(log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow
const isRetryable = log?.status === 'failed' && hasWorkflow && log?.trigger !== 'mothership'
Expand Down Expand Up @@ -112,7 +119,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
</DropdownMenuItem>

<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onOpenWorkflow}>
<DropdownMenuItem disabled={!hasOpenableWorkflow} onSelect={onOpenWorkflow}>
<SquareArrowUpRight />
Open Workflow
</DropdownMenuItem>
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ import {
getDisplayStatus,
type LogStatus,
parseDuration,
resolveLogWorkflowId,
STATUS_CONFIG,
StatusBadge,
TriggerBadge,
workflowEditorPath,
} from './utils'

const LOGS_PER_PAGE = 50 as const
Expand Down Expand Up @@ -524,9 +526,9 @@ export default function Logs() {
}, [contextMenuLog, workspaceId])

const handleOpenWorkflow = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
const wfId = contextMenuLog ? resolveLogWorkflowId(contextMenuLog) : null
if (wfId) {
window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank')
window.open(workflowEditorPath(workspaceId, wfId), '_blank')
}
}, [contextMenuLog, workspaceId])

Expand Down
55 changes: 55 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/logs/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest'
import { resolveLogWorkflowId, workflowEditorPath } from './utils'

describe('resolveLogWorkflowId', () => {
it('returns the nested workflow id when present', () => {
expect(
resolveLogWorkflowId({ trigger: 'manual', workflowId: 'wf-1', workflow: { id: 'wf-1' } })
).toBe('wf-1')
})

it('falls back to workflowId when the workflow object is absent', () => {
expect(resolveLogWorkflowId({ trigger: 'api', workflowId: 'wf-2', workflow: null })).toBe(
'wf-2'
)
})

it('prefers the nested workflow id over workflowId when both are set', () => {
expect(
resolveLogWorkflowId({ trigger: 'manual', workflowId: 'stale', workflow: { id: 'fresh' } })
).toBe('fresh')
})

it('returns null for Sim agent jobs even when a workflow id exists', () => {
expect(
resolveLogWorkflowId({
trigger: 'mothership',
workflowId: 'wf-3',
workflow: { id: 'wf-3' },
})
).toBeNull()
})

it('returns null for a deleted workflow (both id fields empty)', () => {
expect(resolveLogWorkflowId({ trigger: 'manual', workflowId: null, workflow: null })).toBeNull()
})

it('returns null when ids are present but empty strings', () => {
expect(
resolveLogWorkflowId({ trigger: 'manual', workflowId: '', workflow: { id: '' } })
).toBeNull()
})

it('treats a missing trigger as a normal workflow run', () => {
expect(resolveLogWorkflowId({ workflowId: 'wf-4' })).toBe('wf-4')
})
})

describe('workflowEditorPath', () => {
it('builds the workspace-scoped editor path', () => {
expect(workflowEditorPath('ws-1', 'wf-1')).toBe('/workspace/ws-1/w/wf-1')
})
})
23 changes: 23 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/logs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ export const LOG_COLUMNS = {

export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'

/**
* Resolves the workflow a log row points at, or null when there is nowhere to
* navigate. Sim agent jobs have no workflow of their own, and a deleted
* workflow leaves both id fields empty.
*
* Single source of truth for "is this log's workflow reachable" — the list row,
* its context menu, and the details panel must agree, or a row can render as
* "Deleted Workflow" while still linking somewhere.
*/
export function resolveLogWorkflowId(log: {
trigger?: string | null
workflowId?: string | null
workflow?: { id?: string } | null
}): string | null {
if (log.trigger === 'mothership') return null
return log.workflow?.id || log.workflowId || null
}

/** Path to a workflow in the editor. */
export function workflowEditorPath(workspaceId: string, workflowId: string): string {
return `/workspace/${workspaceId}/w/${workflowId}`
}

export type LogStatus =
| 'error'
| 'pending'
Expand Down
Loading