diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md new file mode 100644 index 00000000000..6f9e6322521 --- /dev/null +++ b/.agents/skills/ship/SKILL.md @@ -0,0 +1,82 @@ +--- +name: ship +description: Commit, push, and open a PR to staging in one shot +--- + +# Ship Command + +You help ship code by creating commits, pushing to the remote branch, and creating PRs in the user's voice. + +## Your Task + +When the user runs `/ship`: + +1. **Check git status** - See what files have changed +2. **Generate a commit message** following this format: `type(scope): description` + - Types: `fix`, `feat`, `improvement`, `chore` + - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) + - Keep it concise + +3. **Run lint** - Run `bun run lint` from the repo root to fix formatting issues before staging + +4. **Stage and commit** the changes with the generated message + +5. **Push to origin** using the current branch name + +6. **Create a PR** to staging with a description in the user's voice + +## Commit Message Format + +Based on the repo's commit history: +``` +fix(scope): description for bug fixes +feat(scope): description for new features +improvement(scope): description for enhancements +chore(scope): description for maintenance +``` + +## PR Description Format + +Use this exact template in the user's voice (concise, bullet points): + +```markdown +## Summary +- bullet point describing what changed +- another bullet point if needed + +## Type of Change +- [x] Bug fix (or appropriate type) + +## Testing +Tested manually (or describe testing) + +## Checklist +- [x] Code follows project style guidelines +- [x] Self-reviewed my changes +- [ ] Tests added/updated and passing +- [x] No new warnings introduced +- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) +``` + +## PR Creation Command + +Use this command structure: +```bash +gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" +``` + +## Important Notes + +- Always confirm the commit message and PR description with the user before executing +- The PR should be created against `staging` branch +- Keep descriptions concise and in active voice +- Match the user's previous PR style: direct, no fluff, bullet points +- **DO NOT add "Co-Authored-By" lines to commits** - keep commit messages clean + +## User's Voice Characteristics (based on previous PRs) + +- Short, direct bullet points +- No unnecessary explanation +- "Tested manually" is acceptable for testing section +- Checkboxes filled in appropriately +- No screenshots section unless UI changes diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md new file mode 100644 index 00000000000..6f848cc4a8f --- /dev/null +++ b/.claude/commands/ship.md @@ -0,0 +1,82 @@ +--- +description: Commit, push, and open a PR to staging in one shot +argument-hint: [optional context or scope notes] +--- + +# Ship Command + +You help ship code by creating commits, pushing to the remote branch, and creating PRs in the user's voice. + +## Your Task + +When the user runs `/ship`: + +1. **Check git status** - See what files have changed +2. **Generate a commit message** following this format: `type(scope): description` + - Types: `fix`, `feat`, `improvement`, `chore` + - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) + - Keep it concise + +3. **Run lint** - Run `bun run lint` from the repo root to fix formatting issues before staging + +4. **Stage and commit** the changes with the generated message + +5. **Push to origin** using the current branch name + +6. **Create a PR** to staging with a description in the user's voice + +## Commit Message Format + +Based on the repo's commit history: +``` +fix(scope): description for bug fixes +feat(scope): description for new features +improvement(scope): description for enhancements +chore(scope): description for maintenance +``` + +## PR Description Format + +Use this exact template in the user's voice (concise, bullet points): + +```markdown +## Summary +- bullet point describing what changed +- another bullet point if needed + +## Type of Change +- [x] Bug fix (or appropriate type) + +## Testing +Tested manually (or describe testing) + +## Checklist +- [x] Code follows project style guidelines +- [x] Self-reviewed my changes +- [ ] Tests added/updated and passing +- [x] No new warnings introduced +- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) +``` + +## PR Creation Command + +Use this command structure: +```bash +gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" +``` + +## Important Notes + +- Always confirm the commit message and PR description with the user before executing +- The PR should be created against `staging` branch +- Keep descriptions concise and in active voice +- Match the user's previous PR style: direct, no fluff, bullet points +- **DO NOT add "Co-Authored-By" lines to commits** - keep commit messages clean + +## User's Voice Characteristics (based on previous PRs) + +- Short, direct bullet points +- No unnecessary explanation +- "Tested manually" is acceptable for testing section +- Checkboxes filled in appropriately +- No screenshots section unless UI changes diff --git a/.cursor/commands/ship.md b/.cursor/commands/ship.md new file mode 100644 index 00000000000..41855257c44 --- /dev/null +++ b/.cursor/commands/ship.md @@ -0,0 +1,77 @@ +# Ship Command + +You help ship code by creating commits, pushing to the remote branch, and creating PRs in the user's voice. + +## Your Task + +When the user runs `/ship`: + +1. **Check git status** - See what files have changed +2. **Generate a commit message** following this format: `type(scope): description` + - Types: `fix`, `feat`, `improvement`, `chore` + - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) + - Keep it concise + +3. **Run lint** - Run `bun run lint` from the repo root to fix formatting issues before staging + +4. **Stage and commit** the changes with the generated message + +5. **Push to origin** using the current branch name + +6. **Create a PR** to staging with a description in the user's voice + +## Commit Message Format + +Based on the repo's commit history: +``` +fix(scope): description for bug fixes +feat(scope): description for new features +improvement(scope): description for enhancements +chore(scope): description for maintenance +``` + +## PR Description Format + +Use this exact template in the user's voice (concise, bullet points): + +```markdown +## Summary +- bullet point describing what changed +- another bullet point if needed + +## Type of Change +- [x] Bug fix (or appropriate type) + +## Testing +Tested manually (or describe testing) + +## Checklist +- [x] Code follows project style guidelines +- [x] Self-reviewed my changes +- [ ] Tests added/updated and passing +- [x] No new warnings introduced +- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) +``` + +## PR Creation Command + +Use this command structure: +```bash +gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY" +``` + +## Important Notes + +- Always confirm the commit message and PR description with the user before executing +- The PR should be created against `staging` branch +- Keep descriptions concise and in active voice +- Match the user's previous PR style: direct, no fluff, bullet points +- **DO NOT add "Co-Authored-By" lines to commits** - keep commit messages clean + +## User's Voice Characteristics (based on previous PRs) + +- Short, direct bullet points +- No unnecessary explanation +- "Tested manually" is acceptable for testing section +- Checkboxes filled in appropriately +- No screenshots section unless UI changes diff --git a/apps/docs/content/docs/en/tools/sap_s4hana.mdx b/apps/docs/content/docs/en/tools/sap_s4hana.mdx index 57fde4ba95d..0c8aaf5c745 100644 --- a/apps/docs/content/docs/en/tools/sap_s4hana.mdx +++ b/apps/docs/content/docs/en/tools/sap_s4hana.mdx @@ -1,19 +1,19 @@ --- -title: SAP S/4HANA -description: Read and write SAP S/4HANA Cloud business data via OData +title: SAP S4HANA +description: Read and write SAP S4HANA Cloud business data via OData --- import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} -[SAP S/4HANA](https://www.sap.com/products/erp/s4hana.html) is SAP's flagship intelligent ERP suite, running on the in-memory HANA database. It powers finance, supply chain, procurement, sales, and manufacturing for organizations of every size, and exposes its business data through a broad catalog of OData services on SAP Business Technology Platform (BTP). +[SAP S4HANA](https://www.sap.com/products/erp/s4hana.html) is SAP's flagship intelligent ERP suite, running on the in-memory HANA database. It powers finance, supply chain, procurement, sales, and manufacturing for organizations of every size, and exposes its business data through a broad catalog of OData services on SAP Business Technology Platform (BTP). -With SAP S/4HANA, you can: +With SAP S4HANA, you can: - **Run core business processes**: Manage finance, procurement, sales, logistics, inventory, and manufacturing on a single source of truth. - **Model master data at scale**: Maintain business partners, customers, suppliers, products, and organizational structures across multiple company codes, sales organizations, and plants. @@ -21,22 +21,22 @@ With SAP S/4HANA, you can: - **Govern access cleanly**: Use Communication Arrangements, Communication Systems, and Communication Scopes to scope OAuth client credentials to exactly the services each integration needs. - **Integrate via standard OData**: Every entity supported here speaks OData v2 with consistent paging, filtering, expansion, and ETag-based optimistic concurrency. -In Sim, the SAP S/4HANA integration lets your agents read and write directly against your tenant's OData services using per-tenant OAuth 2.0 client credentials. Agents can list and fetch master data, create and update transactional documents, run stock and material document queries, and execute arbitrary OData v2 calls against any whitelisted Communication Scenario — all routed through a single internal proxy that handles token acquisition, CSRF fetch-and-retry, and OData error normalization. Use it to automate order-to-cash, procure-to-pay, and inventory workflows, keep SAP in sync with the rest of your stack, or trigger downstream agent logic from SAP business events. +In Sim, the SAP S4HANA integration lets your agents read and write directly against your tenant's OData services using per-tenant OAuth 2.0 client credentials. Agents can list and fetch master data, create and update transactional documents, run stock and material document queries, and execute arbitrary OData v2 calls against any whitelisted Communication Scenario — all routed through a single internal proxy that handles token acquisition, CSRF fetch-and-retry, and OData error normalization. Use it to automate order-to-cash, procure-to-pay, and inventory workflows, keep SAP in sync with the rest of your stack, or trigger downstream agent logic from SAP business events. {/* MANUAL-CONTENT-END */} ## Usage Instructions {/* MANUAL-CONTENT-START:usage */} -Connect any SAP S/4HANA tenant — **Cloud Public Edition**, **Cloud Private Edition (RISE)**, or **on-premise** — and read or write business data through the official OData v2 services. Each tool routes through a single internal proxy that handles token acquisition, CSRF fetch-and-retry for write operations, and OData error normalization. +Connect any SAP S4HANA tenant — **Cloud Public Edition**, **Cloud Private Edition (RISE)**, or **on-premise** — and read or write business data through the official OData v2 services. Each tool routes through a single internal proxy that handles token acquisition, CSRF fetch-and-retry for write operations, and OData error normalization. ### Deployment modes Pick the deployment that matches your tenant in the **Deployment** dropdown: -- **S/4HANA Cloud Public Edition** — provide your **BTP subaccount subdomain** and **region** (e.g., `eu10`, `us10`). The host is derived automatically as `{subdomain}-api.s4hana.ondemand.com`, and OAuth tokens are fetched from the matching BTP UAA endpoint. Authentication is OAuth 2.0 client credentials configured in a Communication Arrangement. -- **S/4HANA Cloud Private Edition (RISE)** — provide your **OData Base URL** (e.g., `https://my-tenant.s4hana.cloud.sap`). Authenticate with **OAuth 2.0 client credentials** (provide the tenant's UAA `tokenUrl`, `clientId`, `clientSecret`) or **HTTP Basic** with a Communication User (`username`, `password`). -- **On-premise S/4HANA** — provide your **OData Base URL** (e.g., `https://sap.internal.company.com:44300`). Authenticate with **OAuth 2.0 client credentials** issued by your on-prem identity provider, or **HTTP Basic** with a service user. +- **S4HANA Cloud Public Edition** — provide your **BTP subaccount subdomain** and **region** (e.g., `eu10`, `us10`). The host is derived automatically as `{subdomain}-api.s4hana.ondemand.com`, and OAuth tokens are fetched from the matching BTP UAA endpoint. Authentication is OAuth 2.0 client credentials configured in a Communication Arrangement. +- **S4HANA Cloud Private Edition (RISE)** — provide your **OData Base URL** (e.g., `https://my-tenant.s4hana.cloud.sap`). Authenticate with **OAuth 2.0 client credentials** (provide the tenant's UAA `tokenUrl`, `clientId`, `clientSecret`) or **HTTP Basic** with a Communication User (`username`, `password`). +- **On-premise S4HANA** — provide your **OData Base URL** (e.g., `https://sap.internal.company.com:44300`). Authenticate with **OAuth 2.0 client credentials** issued by your on-prem identity provider, or **HTTP Basic** with a service user. ### What you can do @@ -48,7 +48,7 @@ All update tools accept an optional `ifMatch` ETag. When omitted, `If-Match` def {/* MANUAL-CONTENT-END */} -Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario. +Connect SAP S4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario. diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index c721a07291b..2959cf699cf 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -98,11 +98,7 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S const [showEmailValidationError, setShowEmailValidationError] = useState(false) const [formError, setFormError] = useState(null) const turnstileRef = useRef(null) - const [turnstileSiteKey, setTurnstileSiteKey] = useState() - - useEffect(() => { - setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) - }, []) + const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || '' const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false const invalidCallbackRef = useRef(false) diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index d7a213f2499..7b3fb99bc9e 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Loader2, X } from 'lucide-react' import Image from 'next/image' @@ -88,24 +88,21 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal } }, [open, providerStatus, hasModalContent, defaultView, router, view]) - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (nextOpen && providerStatus && !hasModalContent) { - router.push(defaultView === 'login' ? '/login' : '/signup') - return - } - setOpen(nextOpen) - if (nextOpen) { - const initialView = - defaultView === 'signup' && providerStatus?.registrationDisabled ? 'login' : defaultView - setView(initialView) - captureClientEvent('auth_modal_opened', { view: initialView, source }) - } - }, - [defaultView, hasModalContent, providerStatus, router, source] - ) + function handleOpenChange(nextOpen: boolean) { + if (nextOpen && providerStatus && !hasModalContent) { + router.push(defaultView === 'login' ? '/login' : '/signup') + return + } + setOpen(nextOpen) + if (nextOpen) { + const initialView = + defaultView === 'signup' && providerStatus?.registrationDisabled ? 'login' : defaultView + setView(initialView) + captureClientEvent('auth_modal_opened', { view: initialView, source }) + } + } - const handleSocialLogin = useCallback(async (provider: 'github' | 'google') => { + async function handleSocialLogin(provider: 'github' | 'google') { setSocialLoading(provider) try { await client.signIn.social({ provider, callbackURL: '/workspace' }) @@ -114,17 +111,17 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal } finally { setSocialLoading(null) } - }, []) + } - const handleSSOLogin = useCallback(() => { + function handleSSOLogin() { setOpen(false) router.push('/sso') - }, [router]) + } - const handleEmailContinue = useCallback(() => { + function handleEmailContinue() { setOpen(false) router.push(view === 'login' ? '/login' : '/signup') - }, [router, view]) + } return ( diff --git a/apps/sim/app/(landing)/components/contact/contact-form.tsx b/apps/sim/app/(landing)/components/contact/contact-form.tsx index 11030ac760a..879f28133bc 100644 --- a/apps/sim/app/(landing)/components/contact/contact-form.tsx +++ b/apps/sim/app/(landing)/components/contact/contact-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { toError } from '@sim/utils/errors' import { useMutation } from '@tanstack/react-query' @@ -99,11 +99,7 @@ export function ContactForm() { const [isSubmitting, setIsSubmitting] = useState(false) const [website, setWebsite] = useState('') const [widgetReady, setWidgetReady] = useState(false) - const [turnstileSiteKey, setTurnstileSiteKey] = useState() - - useEffect(() => { - setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) - }, []) + const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) function updateField( field: TField, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 1f8c92d35fe..b11c74c5f47 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -11381,11 +11381,11 @@ }, { "type": "sap_s4hana", - "slug": "sap-s-4hana", - "name": "SAP S/4HANA", - "description": "Read and write SAP S/4HANA Cloud business data via OData", - "longDescription": "Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.", - "bgColor": "#0A6ED1", + "slug": "sap-s4hana", + "name": "SAP S4HANA", + "description": "Read and write SAP S4HANA Cloud business data via OData", + "longDescription": "Connect SAP S4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.", + "bgColor": "#FFFFFF", "iconName": "SapS4HanaIcon", "docsUrl": "https://docs.sim.ai/tools/sap_s4hana", "operations": [ diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 7d74c421262..9bd5e3d41c1 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -120,17 +120,22 @@ export const GET = withRouteHandler(async (request: Request) => { } const encryptedVariables = result[0].variables as Record - const decryptedVariables: Record = {} - - for (const [key, encryptedValue] of Object.entries(encryptedVariables)) { - try { - const { decrypted } = await decryptSecret(encryptedValue) - decryptedVariables[key] = { key, value: decrypted } - } catch (error) { - logger.error(`[${requestId}] Error decrypting variable ${key}`, error) - decryptedVariables[key] = { key, value: '' } - } - } + + const decryptedEntries = await Promise.all( + Object.entries(encryptedVariables).map(async ([key, encryptedValue]) => { + try { + const { decrypted } = await decryptSecret(encryptedValue) + return [key, { key, value: decrypted }] as const + } catch (error) { + logger.error(`[${requestId}] Error decrypting variable ${key}`, error) + return [key, { key, value: '' }] as const + } + }) + ) + const decryptedVariables = Object.fromEntries(decryptedEntries) as Record< + string, + EnvironmentVariable + > return NextResponse.json({ data: decryptedVariables }, { status: 200 }) } catch (error: any) { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 32792f1f367..9ba68c197d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -313,7 +313,10 @@ export const ResourceTable = memo(function ResourceTable({ -
+
@@ -562,7 +565,7 @@ const ResourceColGroup = memo(function ResourceColGroup({ key={col.id} style={ colIdx === 0 - ? { minWidth: 200 * (col.widthMultiplier ?? 1) } + ? { width: 400 * (col.widthMultiplier ?? 1) } : { width: 160 * (col.widthMultiplier ?? 1) } } /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0a58d8c2b34..bfa032ee2bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,6 +1,6 @@ 'use client' -import { createElement, useEffect, useMemo, useState } from 'react' +import { createElement, useMemo, useState } from 'react' import { useParams } from 'next/navigation' import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -403,15 +403,11 @@ interface OptionsDisplayProps { function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) { const disabled = !onSelect - const [expanded, setExpanded] = useState(!disabled) + const [collapsedByUser, setCollapsedByUser] = useState(false) + // When interactive (not disabled), always expanded. When disabled, the user can toggle. + const expanded = !disabled || !collapsedByUser const entries = Object.entries(data) - useEffect(() => { - if (!disabled) { - setExpanded(true) - } - }, [disabled]) - if (entries.length === 0) return null return ( @@ -419,7 +415,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) { {disabled ? ( + ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts index 3c0c5922adf..59310eeb4e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts @@ -3,6 +3,7 @@ export { LogDetails, WorkflowOutputSection } from './log-details' export { ExecutionSnapshot } from './log-details/components/execution-snapshot' export { FileCards } from './log-details/components/file-download' export { TraceSpans } from './log-details/components/trace-spans' +export { TraceView } from './log-details/components/trace-view' export { LogRowContextMenu } from './log-row-context-menu' export { LogsList } from './logs-list' export { AutocompleteSearch, LogsToolbar, NotificationSettings } from './logs-toolbar' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx index 2f2ebd93182..3a2c2af0d42 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -1,9 +1,11 @@ 'use client' -import { useCallback, useRef, useState } from 'react' +import type React from 'react' +import { useRef, useState } from 'react' import { AlertCircle, Loader2 } from 'lucide-react' import { createPortal } from 'react-dom' import { + Copy, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -13,7 +15,6 @@ import { ModalContent, ModalHeader, } from '@/components/emcn' -import { Copy } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { Preview } from '@/app/workspace/[workspaceId]/w/components/preview' import { useExecutionSnapshot } from '@/hooks/queries/logs' @@ -64,21 +65,21 @@ export function ExecutionSnapshot({ const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) const menuRef = useRef(null) - const closeMenu = useCallback(() => { + function closeMenu() { setIsMenuOpen(false) - }, []) + } - const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => { + function handleCanvasContextMenu(e: React.MouseEvent) { e.preventDefault() e.stopPropagation() setMenuPosition({ x: e.clientX, y: e.clientY }) setIsMenuOpen(true) - }, []) + } - const handleCopyExecutionId = useCallback(() => { + function handleCopyExecutionId() { navigator.clipboard.writeText(executionId) closeMenu() - }, [executionId, closeMenu]) + } const workflowState = data?.workflowState as WorkflowState | undefined const childWorkflowSnapshots = data?.childWorkflowSnapshots as @@ -161,6 +162,7 @@ export function ExecutionSnapshot({ onCanvasContextMenu={handleCanvasContextMenu} showBorder={!isModal} autoSelectLeftmost + showBlockCloseButton={!isModal} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index a6c740a46fa..0a047c89c3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -9,22 +9,23 @@ import { Button, ChevronDown, Code, + Copy as CopyIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, + Search as SearchIcon, Tooltip, } from '@/components/emcn' -import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' +import type { TraceSpan } from '@/lib/logs/types' import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getBlock, getBlockByToolName } from '@/blocks' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' -import type { TraceSpan } from '@/stores/logs/filters/types' interface TraceSpansProps { traceSpans?: TraceSpan[] @@ -58,6 +59,86 @@ function useSetToggle() { ) } +/** + * Formats a token count with locale-aware thousands separators. + * Returns `undefined` for missing or non-positive counts so callers can + * filter them out before rendering. + */ +function formatTokenCount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + return value.toLocaleString('en-US') +} + +/** + * Builds a compact, dot-separated token summary for a span: + * `"1,234 in · 567 out · 1,801 total"` with cache/reasoning appended when + * present. Returns `undefined` when the span has no meaningful token data. + */ +function formatTokensSummary(tokens: TraceSpan['tokens']): string | undefined { + if (!tokens) return undefined + const parts: string[] = [] + const input = formatTokenCount(tokens.input) + const output = formatTokenCount(tokens.output) + const total = formatTokenCount(tokens.total) + const cacheRead = formatTokenCount(tokens.cacheRead) + const cacheWrite = formatTokenCount(tokens.cacheWrite) + const reasoning = formatTokenCount(tokens.reasoning) + if (input) parts.push(`${input} in`) + if (cacheRead) parts.push(`${cacheRead} cached`) + if (cacheWrite) parts.push(`${cacheWrite} cache write`) + if (output) parts.push(`${output} out`) + if (reasoning) parts.push(`${reasoning} reasoning`) + if (total) parts.push(`${total} total`) + return parts.length > 0 ? parts.join(' · ') : undefined +} + +/** + * Formats a USD cost value for display. Shows `<$0.0001` for non-zero sub-cent + * amounts so the user sees it was counted. + */ +function formatCostAmount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + if (value < 0.0001) return '<$0.0001' + return `$${value.toFixed(4)}` +} + +/** + * Builds a compact cost summary: `"$0.0023 · $0.0001 in · $0.0022 out"`. + * Falls back to whichever parts are present. + */ +function formatCostSummary(cost: TraceSpan['cost']): string | undefined { + if (!cost) return undefined + const parts: string[] = [] + const total = formatCostAmount(cost.total) + const input = formatCostAmount(cost.input) + const output = formatCostAmount(cost.output) + if (total) parts.push(total) + if (input) parts.push(`${input} in`) + if (output) parts.push(`${output} out`) + return parts.length > 0 ? parts.join(' · ') : undefined +} + +/** + * Derives tokens-per-second from output tokens over segment duration. + * Returns `undefined` when inputs are missing or non-positive. + */ +function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined { + if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined + if (!(durationMs > 0)) return undefined + const tps = Math.round(outputTokens / (durationMs / 1000)) + if (!(tps > 0)) return undefined + return `${tps.toLocaleString('en-US')} tok/s` +} + +/** + * Formats time-to-first-token. Uses `ms` below 1000, `s` above. + */ +function formatTtft(ms: number | undefined): string | undefined { + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + /** * Parses a time value to milliseconds */ @@ -185,7 +266,7 @@ function ProgressBar({ const computeSegment = (s: TraceSpan) => { const startMs = new Date(s.startTime).getTime() const endMs = new Date(s.endTime).getTime() - const duration = endMs - startMs + const duration = s.duration || endMs - startMs const startPercent = totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0 const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0 @@ -238,7 +319,7 @@ function InputOutputSection({ data: unknown isError: boolean spanId: string - sectionType: 'input' | 'output' + sectionType: 'input' | 'output' | 'thinking' | 'modelToolCalls' | 'errorMessage' expandedSections: Set onToggle: (section: string) => void }) { @@ -268,31 +349,32 @@ function InputOutputSection({ const jsonString = useMemo(() => { if (!data) return '' + if (typeof data === 'string') return data return JSON.stringify(data, null, 2) }, [data]) - const handleContextMenu = useCallback((e: React.MouseEvent) => { + function handleContextMenu(e: React.MouseEvent) { e.preventDefault() e.stopPropagation() setContextMenuPosition({ x: e.clientX, y: e.clientY }) setIsContextMenuOpen(true) - }, []) + } - const closeContextMenu = useCallback(() => { + function closeContextMenu() { setIsContextMenuOpen(false) - }, []) + } - const handleCopy = useCallback(() => { + function handleCopy() { navigator.clipboard.writeText(jsonString) setCopied(true) setTimeout(() => setCopied(false), 1500) closeContextMenu() - }, [jsonString, closeContextMenu]) + } - const handleSearch = useCallback(() => { + function handleSearch() { activateSearch() closeContextMenu() - }, [activateSearch, closeContextMenu]) + } return (
@@ -513,63 +595,52 @@ const TraceSpanNode = memo(function TraceSpanNode({ const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name) - // Build all children including tool calls - const allChildren = useMemo(() => { - const children: TraceSpan[] = [] - - // Add tool calls as child spans - if (span.toolCalls && span.toolCalls.length > 0) { - span.toolCalls.forEach((toolCall, index) => { - const toolStartTime = toolCall.startTime - ? new Date(toolCall.startTime).getTime() - : spanStartTime - const toolEndTime = toolCall.endTime - ? new Date(toolCall.endTime).getTime() - : toolStartTime + (toolCall.duration || 0) - - children.push({ - id: `${spanId}-tool-${index}`, - name: toolCall.name, + const displayChildren = useMemo(() => { + const kids: TraceSpan[] = span.children?.length + ? [...span.children] + : (span.toolCalls ?? []).map((tc, i) => ({ + id: `${spanId}-tool-${i}`, + name: tc.name, type: 'tool', - duration: toolCall.duration || toolEndTime - toolStartTime, - startTime: new Date(toolStartTime).toISOString(), - endTime: new Date(toolEndTime).toISOString(), - status: toolCall.error ? ('error' as const) : ('success' as const), - input: toolCall.input, - output: toolCall.error - ? { error: toolCall.error, ...(toolCall.output || {}) } - : toolCall.output, - } as TraceSpan) - }) - } - - // Add regular children - if (span.children && span.children.length > 0) { - children.push(...span.children) - } + duration: tc.duration || 0, + startTime: tc.startTime ?? span.startTime, + endTime: tc.endTime ?? span.endTime, + status: tc.error ? ('error' as const) : ('success' as const), + input: tc.input, + output: tc.error ? { error: tc.error, ...(tc.output ?? {}) } : tc.output, + })) - // Sort by start time - return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) - }, [span, spanId, spanStartTime]) + kids.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) - // Hide empty model timing segments for agents without tool calls - const filteredChildren = useMemo(() => { const isAgent = span.type?.toLowerCase() === 'agent' - const hasToolCalls = - (span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool') - - if (isAgent && !hasToolCalls) { - return allChildren.filter((c) => c.type?.toLowerCase() !== 'model') + const hasToolCall = kids.some((c) => c.type?.toLowerCase() === 'tool') + if (isAgent && !hasToolCall) { + return kids.filter((c) => c.type?.toLowerCase() !== 'model') } - return allChildren - }, [allChildren, span.type, span.toolCalls]) + return kids + }, [span]) - const hasChildren = filteredChildren.length > 0 + const hasChildren = displayChildren.length > 0 const isExpanded = isRootWorkflow || expandedNodes.has(spanId) const isToggleable = !isRootWorkflow const hasInput = Boolean(span.input) const hasOutput = Boolean(span.output) + const hasThinking = Boolean(span.thinking) + const hasModelToolCalls = Boolean(span.modelToolCalls && span.modelToolCalls.length > 0) + const hasFinishReason = Boolean(span.finishReason) + const tokensSummary = formatTokensSummary(span.tokens) + const hasTokens = Boolean(tokensSummary) + const costSummary = formatCostSummary(span.cost) + const hasCost = Boolean(costSummary) + const isModelSpan = span.type?.toLowerCase() === 'model' + const tpsSummary = isModelSpan ? formatTps(span.tokens?.output, duration) : undefined + const hasTps = Boolean(tpsSummary) + const ttftSummary = formatTtft(span.ttft) + const hasTtft = Boolean(ttftSummary) + const hasProvider = Boolean(span.provider) + const hasErrorType = Boolean(span.errorType) + const hasErrorMessage = Boolean(span.errorMessage) // For progress bar - show child segments for workflow/iteration types const lowerType = span.type?.toLowerCase() || '' @@ -641,7 +712,18 @@ const TraceSpanNode = memo(function TraceSpanNode({ /> {/* Input/Output Sections */} - {(hasInput || hasOutput) && ( + {(hasInput || + hasOutput || + hasThinking || + hasModelToolCalls || + hasFinishReason || + hasTokens || + hasCost || + hasTps || + hasTtft || + hasProvider || + hasErrorType || + hasErrorMessage) && (
{hasInput && ( )} + + {hasThinking && ( + <> + {(hasInput || hasOutput) && ( +
+ )} + + + )} + + {hasModelToolCalls && ( + <> + {(hasInput || hasOutput || hasThinking) && ( +
+ )} + + + )} + + {hasErrorMessage && ( + <> + {(hasInput || hasOutput || hasThinking || hasModelToolCalls) && ( +
+ )} + + + )} + + {hasErrorType && ( +
+ Error type + + {span.errorType} + +
+ )} + + {hasFinishReason && ( +
+ Finish reason + {span.finishReason} +
+ )} + + {hasProvider && ( +
+ Provider + + {span.provider} + +
+ )} + + {hasTtft && ( +
+ TTFT + + {ttftSummary} + +
+ )} + + {hasTokens && ( +
+ Tokens + + {tokensSummary} + +
+ )} + + {hasTps && ( +
+ Throughput + + {tpsSummary} + +
+ )} + + {hasCost && ( +
+ Cost + + {costSummary} + +
+ )}
)} {/* Nested Children */} {hasChildren && (
- {filteredChildren.map((child, index) => ( + {displayChildren.map((child, index) => (
| null + bgColor: string +} + +/** + * Parses a timestamp or numeric ms into milliseconds since epoch. + */ +function parseTime(value?: string | number | null): number { + if (!value) return 0 + const ms = typeof value === 'number' ? value : new Date(value).getTime() + return Number.isFinite(ms) ? ms : 0 +} + +/** + * Whether a span type represents a loop or parallel iteration container. + */ +function isIterationType(type: string): boolean { + const lower = type?.toLowerCase() || '' + return lower === 'loop-iteration' || lower === 'parallel-iteration' +} + +/** + * Returns the stable id for a span, synthesized when absent. + */ +function getSpanId(span: TraceSpan): string { + return span.id || `span-${span.name}-${span.startTime}` +} + +/** + * Walks a span's descendants to determine if any error exists in the subtree. + */ +function hasErrorInTree(span: TraceSpan): boolean { + if (span.status === 'error') return true + if (span.children?.length) return span.children.some(hasErrorInTree) + if (span.toolCalls?.length) return span.toolCalls.some((tc) => tc.error) + return false +} + +/** + * Like `hasErrorInTree` but only counts errors that were not handled by an + * error-handler path. Used for the root workflow status color. + */ +function hasUnhandledErrorInTree(span: TraceSpan): boolean { + if (span.status === 'error' && !span.errorHandled) return true + if (span.children?.length) return span.children.some(hasUnhandledErrorInTree) + if (span.toolCalls?.length && !span.errorHandled) return span.toolCalls.some((tc) => tc.error) + return false +} + +/** + * Normalizes and sorts a tree of spans by start time. + */ +function normalizeAndSort(spans: TraceSpan[]): TraceSpan[] { + return spans + .map((span) => ({ + ...span, + children: span.children?.length ? normalizeAndSort(span.children) : undefined, + })) + .sort((a, b) => { + const d = parseTime(a.startTime) - parseTime(b.startTime) + return d !== 0 ? d : parseTime(a.endTime) - parseTime(b.endTime) + }) +} + +/** + * For agents with no tool calls, hides synthetic model-segment children to + * avoid noise in the tree. + */ +function getDisplayChildren(span: TraceSpan): TraceSpan[] { + const kids: TraceSpan[] = span.children?.length + ? [...span.children] + : (span.toolCalls ?? []).map((tc, i) => ({ + id: `${getSpanId(span)}-tool-${i}`, + name: tc.name, + type: 'tool', + duration: tc.duration || 0, + startTime: tc.startTime ?? span.startTime, + endTime: tc.endTime ?? span.endTime, + status: tc.error ? ('error' as const) : ('success' as const), + input: tc.input, + output: tc.error ? { error: tc.error, ...(tc.output ?? {}) } : tc.output, + })) + kids.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) + const isAgent = span.type?.toLowerCase() === 'agent' + const hasToolCall = kids.some((c) => c.type?.toLowerCase() === 'tool') + if (isAgent && !hasToolCall) return kids.filter((c) => c.type?.toLowerCase() !== 'model') + return kids +} + +/** + * Resolves the block icon and accent color for a trace span type. + */ +function getBlockAppearance(type: string, toolName?: string, provider?: string): BlockAppearance { + const lowerType = type.toLowerCase() + if (lowerType === 'tool' && toolName) { + if (toolName === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } + const toolBlock = getBlockByToolName(toolName) + if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor } + } + if (lowerType === 'loop' || lowerType === 'loop-iteration') + return { icon: LoopTool.icon, bgColor: LoopTool.bgColor } + if (lowerType === 'parallel' || lowerType === 'parallel-iteration') + return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor } + if (lowerType === 'workflow') return { icon: WorkflowIcon, bgColor: '#6366F1' } + if (lowerType === 'model' && provider) { + const providerDef = PROVIDER_DEFINITIONS[provider] + if (providerDef?.icon) { + return { icon: providerDef.icon, bgColor: providerDef.color ?? DEFAULT_BLOCK_COLOR } + } + } + const blockType = lowerType === 'model' ? 'agent' : lowerType + const blockConfig = getBlock(blockType) + if (blockConfig) return { icon: blockConfig.icon, bgColor: blockConfig.bgColor } + return { icon: null, bgColor: DEFAULT_BLOCK_COLOR } +} + +/** Returns 'text-white' for dark backgrounds, dark text for light ones. */ +function iconColorClass(bgColor: string): string { + const hex = bgColor.replace('#', '') + if (hex.length !== 6) return 'text-white' + const r = Number.parseInt(hex.slice(0, 2), 16) + const g = Number.parseInt(hex.slice(2, 4), 16) + const b = Number.parseInt(hex.slice(4, 6), 16) + return r * 299 + g * 587 + b * 114 > 160_000 ? 'text-[#111111]' : 'text-white' +} + +function formatTokenCount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + return value.toLocaleString('en-US') +} + +function formatCostAmount(value: number | undefined): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined + if (value < 0.0001) return '<$0.0001' + return `$${value.toFixed(4)}` +} + +function formatTtft(ms: number | undefined): string | undefined { + if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return undefined + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatTps(outputTokens: number | undefined, durationMs: number): string | undefined { + if (typeof outputTokens !== 'number' || !(outputTokens > 0)) return undefined + if (!(durationMs > 0)) return undefined + const tps = Math.round(outputTokens / (durationMs / 1000)) + return tps > 0 ? `${tps.toLocaleString('en-US')} tok/s` : undefined +} + +/** + * Flattens the visible (expanded) span tree into a linear list for keyboard + * navigation, carrying depth, the chain of parent ids for indent drawing, and + * the immediate parent's duration for percentage-of-parent calculations. + */ +function flattenVisible(spans: TraceSpan[], expanded: Set): FlatSpanEntry[] { + const out: FlatSpanEntry[] = [] + const walk = ( + list: TraceSpan[], + depth: number, + parents: string[], + parentDuration: number | undefined + ) => { + for (const span of list) { + const id = getSpanId(span) + out.push({ span, depth, parentIds: parents, parentDuration }) + const children = getDisplayChildren(span) + if (children.length > 0 && expanded.has(id)) { + const ownDuration = span.duration || parseTime(span.endTime) - parseTime(span.startTime) + walk(children, depth + 1, [...parents, id], ownDuration) + } + } + } + walk(spans, 0, [], undefined) + return out +} + +/** + * Returns every descendant span id in the tree. + */ +function collectAllIds(spans: TraceSpan[]): string[] { + const out: string[] = [] + const walk = (list: TraceSpan[]) => { + for (const span of list) { + out.push(getSpanId(span)) + const children = getDisplayChildren(span) + if (children.length > 0) walk(children) + } + } + walk(spans) + return out +} + +/** + * Finds the leaf-most errored span — the actual error source rather than a + * parent span that has its status propagated up from a child. When an errored + * span has errored children, we recurse into those children first; we only + * return the current span if none of its descendants are also errored. + */ +function findLeafErrorSpan(spans: TraceSpan[]): TraceSpan | null { + for (const span of spans) { + if (span.status === 'error') { + const children = getDisplayChildren(span) + const childError = findLeafErrorSpan(children) + return childError ?? span + } + const children = getDisplayChildren(span) + if (children.length > 0) { + const found = findLeafErrorSpan(children) + if (found) return found + } + } + return null +} + +/** + * Finds a span by id anywhere in the tree. + */ +function findSpan(spans: TraceSpan[], id: string | null): TraceSpan | null { + if (!id) return null + for (const span of spans) { + if (getSpanId(span) === id) return span + const children = getDisplayChildren(span) + if (children.length > 0) { + const found = findSpan(children, id) + if (found) return found + } + } + return null +} + +/** + * Case-insensitive name match. + */ +function spanMatchesQuery(span: TraceSpan, query: string): boolean { + if (!query) return true + return (span.name ?? '').toLowerCase().includes(query.toLowerCase()) +} + +/** + * Returns the set of ids of spans that match the query themselves or contain + * a matching descendant. Used to show only relevant branches while preserving + * their parents. + */ +function collectMatchingIds(spans: TraceSpan[], query: string): Set { + const matches = new Set() + const walk = (list: TraceSpan[]): boolean => { + let anyMatch = false + for (const span of list) { + const id = getSpanId(span) + const children = getDisplayChildren(span) + const childMatch = children.length > 0 ? walk(children) : false + const selfMatch = spanMatchesQuery(span, query) + if (selfMatch || childMatch) { + matches.add(id) + anyMatch = true + } + } + return anyMatch + } + walk(spans) + return matches +} + +/** + * Row in the tree pane. Renders the span icon, name, duration, a hover tooltip + * with timing context, and a Gantt-style mini timeline bar below the row so the + * span's position within the run is visible at a glance. Clicking selects the + * span; the chevron toggles expansion. + */ +const TraceTreeRow = memo(function TraceTreeRow({ + entry, + isSelected, + isExpanded, + canExpand, + onSelect, + onToggleExpand, + matchQuery, + runStartMs, + runTotalMs, +}: { + entry: FlatSpanEntry + isSelected: boolean + isExpanded: boolean + canExpand: boolean + onSelect: (id: string) => void + onToggleExpand: (id: string) => void + matchQuery: string + runStartMs: number + runTotalMs: number +}) { + const { span, depth, parentDuration } = entry + const id = getSpanId(span) + const startMs = parseTime(span.startTime) + const endMs = parseTime(span.endTime) + const duration = span.duration || endMs - startMs + const isRootWorkflow = depth === 0 && span.type?.toLowerCase() === 'workflow' + const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span) + const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name, span.provider) + const nameMatches = !!matchQuery && spanMatchesQuery(span, matchQuery) + + const offsetMs = runStartMs > 0 ? Math.max(0, startMs - runStartMs) : 0 + const offsetPct = runTotalMs > 0 ? Math.min(100 - MIN_BAR_PCT, (offsetMs / runTotalMs) * 100) : 0 + const rawDurationPct = runTotalMs > 0 ? (duration / runTotalMs) * 100 : 0 + const durationPct = Math.max(MIN_BAR_PCT, Math.min(100 - offsetPct, rawDurationPct)) + const pctOfTotal = runTotalMs > 0 ? (duration / runTotalMs) * 100 : null + const pctOfParent = + parentDuration && parentDuration > 0 ? (duration / parentDuration) * 100 : null + + return ( +
onSelect(id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(id) + } + }} + role='treeitem' + tabIndex={isSelected ? 0 : -1} + aria-selected={isSelected} + aria-expanded={canExpand ? isExpanded : undefined} + aria-level={depth + 1} + data-span-id={id} + > +
+ {canExpand ? ( + + ) : ( +
+ )} + {!isIterationType(span.type) && ( +
+ {BlockIcon && ( + + )} +
+ )} + + + + {span.name} + + + +
+ {span.name} + + {formatDuration(duration, { precision: 2 }) || '—'} + {offsetMs > 0 && ` · +${formatDuration(offsetMs, { precision: 2 })}`} + + {pctOfTotal !== null && pctOfTotal >= 0.1 && ( + + {pctOfTotal.toFixed(pctOfTotal >= 10 ? 0 : 1)}% of total + {pctOfParent !== null && + pctOfParent >= 0.1 && + ` · ${pctOfParent.toFixed(pctOfParent >= 10 ? 0 : 1)}% of parent`} + + )} +
+
+
+ + {formatDuration(duration, { precision: 2 })} + +
+
+
+
+
+
+
+ ) +}) + +/** + * Collapsible code viewer with copy/search overlay, used for input/output/thinking/ + * tool-call/error blobs in the detail pane. + */ +function DetailCodeSection({ + label, + data, + isError, + defaultOpen = true, +}: { + label: string + data: unknown + isError?: boolean + defaultOpen?: boolean +}) { + const [isOpen, setIsOpen] = useState(defaultOpen) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + const [copied, setCopied] = useState(false) + const contentRef = useRef(null) + + const { + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) + + const jsonString = useMemo(() => { + if (data == null) return '' + if (typeof data === 'string') return data + return JSON.stringify(data, null, 2) + }, [data]) + + function handleContextMenu(e: React.MouseEvent) { + e.preventDefault() + e.stopPropagation() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setIsContextMenuOpen(true) + } + + function handleCopy() { + navigator.clipboard.writeText(jsonString) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + setIsContextMenuOpen(false) + } + + function handleSearch() { + activateSearch() + setIsContextMenuOpen(false) + } + + return ( +
+
setIsOpen((v) => !v)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setIsOpen((v) => !v) + } + }} + role='button' + tabIndex={0} + aria-expanded={isOpen} + > + + {label} + + +
+ {isOpen && ( + <> +
+ + {!isSearchActive && ( +
+ + + + + {copied ? 'Copied' : 'Copy'} + + + + + + Search + +
+ )} +
+ {isSearchActive && ( +
e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-0.5 h-[23px] w-[94px] text-caption' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + + +
+ )} + {typeof document !== 'undefined' && + createPortal( + setIsContextMenuOpen(false)} + modal={false} + > + +
+ + e.preventDefault()} + > + + + Copy + + + + + Search + + + , + document.body + )} + + )} +
+ ) +} + +/** + * A single label:value row in the metadata block of the detail pane. + */ +function MetaRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +/** + * Right-side pane. Renders a header and the available content sections for + * the selected span: metadata, input, output, thinking, tool calls, error. + */ +const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpan | null }) { + if (!span) { + return ( +
+ Select a span to see details. +
+ ) + } + + const duration = span.duration || parseTime(span.endTime) - parseTime(span.startTime) + const { icon: BlockIcon, bgColor } = getBlockAppearance(span.type, span.name, span.provider) + const isRootWorkflow = span.type?.toLowerCase() === 'workflow' + const hasError = isRootWorkflow ? hasUnhandledErrorInTree(span) : hasErrorInTree(span) + const isDirectError = span.status === 'error' + const isModelSpan = span.type?.toLowerCase() === 'model' + + const startedAt = parseTime(span.startTime) + const endedAt = parseTime(span.endTime) + + const metaEntries: { label: string; value: string }[] = [] + metaEntries.push({ label: 'Type', value: span.type }) + metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' }) + if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider }) + if (span.model) metaEntries.push({ label: 'Model', value: span.model }) + if (span.finishReason) metaEntries.push({ label: 'Finish reason', value: span.finishReason }) + const ttftFormatted = formatTtft(span.ttft) + if (ttftFormatted) metaEntries.push({ label: 'TTFT', value: ttftFormatted }) + const tpsFormatted = isModelSpan ? formatTps(span.tokens?.output, duration) : undefined + if (tpsFormatted) metaEntries.push({ label: 'Throughput', value: tpsFormatted }) + const inputTokens = formatTokenCount(span.tokens?.input) + const outputTokens = formatTokenCount(span.tokens?.output) + const totalTokens = formatTokenCount(span.tokens?.total) + const cacheRead = formatTokenCount(span.tokens?.cacheRead) + const cacheWrite = formatTokenCount(span.tokens?.cacheWrite) + const reasoning = formatTokenCount(span.tokens?.reasoning) + if (inputTokens) metaEntries.push({ label: 'Input tokens', value: inputTokens }) + if (outputTokens) metaEntries.push({ label: 'Output tokens', value: outputTokens }) + if (totalTokens) metaEntries.push({ label: 'Total tokens', value: totalTokens }) + if (cacheRead) metaEntries.push({ label: 'Cache read', value: cacheRead }) + if (cacheWrite) metaEntries.push({ label: 'Cache write', value: cacheWrite }) + if (reasoning) metaEntries.push({ label: 'Reasoning tokens', value: reasoning }) + const costTotal = formatCostAmount(span.cost?.total) + if (costTotal) metaEntries.push({ label: 'Cost', value: costTotal }) + if (span.errorType) metaEntries.push({ label: 'Error type', value: span.errorType }) + if (span.iterationIndex !== undefined) + metaEntries.push({ label: 'Iteration', value: String(span.iterationIndex + 1) }) + + const statusLabel = hasError ? 'Error' : 'Success' + + return ( +
+
+ {!isIterationType(span.type) && ( +
+ {BlockIcon && ( + + )} +
+ )} +
+

+ {span.name} +

+
+ + {statusLabel} + + · + {formatDuration(duration, { precision: 2 }) || '—'} + {Number.isFinite(startedAt) && startedAt > 0 && ( + <> + · + + {new Date(startedAt).toLocaleTimeString()} + + + )} +
+
+
+ + {metaEntries.length > 0 && ( +
+ {metaEntries.map((m) => ( + + ))} +
+ )} + + {/* Keys by label: without them, React reused a single DetailCodeSection + across span changes and carried isOpen between sections with different + labels — a collapsed Output on one span appeared as a collapsed Input + on the next. */} + {span.input !== undefined && span.input !== null && ( + + )} + {span.output !== undefined && span.output !== null && ( + + )} + {span.thinking && } + {span.modelToolCalls && span.modelToolCalls.length > 0 && ( + + )} + {span.errorMessage && ( + + )} + + {Number.isFinite(startedAt) && Number.isFinite(endedAt) && startedAt > 0 && endedAt > 0 && ( +
+ + Started {new Date(startedAt).toLocaleTimeString()} + + + Ended {new Date(endedAt).toLocaleTimeString()} + +
+ )} +
+ ) +}) + +/** + * Rich two-pane trace view: hierarchical span tree on the left with + * keyboard-navigable selection, detail pane on the right. Renders the run + * in a way that mirrors the executor's internal structure so investigators can + * follow block-by-block and segment-by-segment what happened and why. + */ +export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) { + const treeRef = useRef(null) + const [searchQuery, setSearchQuery] = useState('') + const [treePaneWidth, setTreePaneWidth] = useState(DEFAULT_TREE_PANE_WIDTH) + const treePaneWidthRef = useRef(DEFAULT_TREE_PANE_WIDTH) + treePaneWidthRef.current = treePaneWidth + const isResizingRef = useRef(false) + const startXRef = useRef(0) + const startWidthRef = useRef(0) + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current) return + const delta = e.clientX - startXRef.current + setTreePaneWidth( + Math.max(MIN_TREE_PANE_WIDTH, Math.min(MAX_TREE_PANE_WIDTH, startWidthRef.current + delta)) + ) + } + const handleMouseUp = () => { + if (!isResizingRef.current) return + isResizingRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + }, []) + + const { + normalizedSpans, + allIds, + totalDuration, + runStartMs, + firstRootId, + firstErrorId, + blockCount, + } = useMemo(() => { + const sorted = normalizeAndSort(traceSpans ?? []) + let earliest = Number.POSITIVE_INFINITY + let latest = 0 + const walkTimeBounds = (spans: TraceSpan[]) => { + for (const span of spans) { + const s = parseTime(span.startTime) + const e = parseTime(span.endTime) + if (s < earliest) earliest = s + if (e > latest) latest = e + if (span.children?.length) walkTimeBounds(span.children) + } + } + walkTimeBounds(sorted) + const ids = collectAllIds(sorted) + const count = ids.length + const runStart = earliest !== Number.POSITIVE_INFINITY ? earliest : 0 + const firstError = findLeafErrorSpan(sorted) + return { + normalizedSpans: sorted, + allIds: ids, + totalDuration: latest > runStart ? latest - runStart : 0, + runStartMs: runStart, + firstRootId: sorted.length > 0 ? getSpanId(sorted[0]) : null, + firstErrorId: firstError ? getSpanId(firstError) : null, + blockCount: count, + } + }, [traceSpans]) + + const [expandedNodes, setExpandedNodes] = useState>(() => new Set(allIds)) + const [selectedId, setSelectedId] = useState(firstErrorId ?? firstRootId) + const [prevAllIds, setPrevAllIds] = useState(allIds) + if (prevAllIds !== allIds) { + setPrevAllIds(allIds) + setExpandedNodes(new Set(allIds)) + setSelectedId(firstErrorId ?? firstRootId) + } + + const matchingIds = useMemo( + () => (searchQuery ? collectMatchingIds(normalizedSpans, searchQuery) : null), + [normalizedSpans, searchQuery] + ) + + const flatList = useMemo(() => { + const visible = flattenVisible(normalizedSpans, expandedNodes) + if (!matchingIds) return visible + return visible.filter((entry) => matchingIds.has(getSpanId(entry.span))) + }, [normalizedSpans, expandedNodes, matchingIds]) + + const selectedSpan = useMemo( + () => findSpan(normalizedSpans, selectedId), + [normalizedSpans, selectedId] + ) + + const runStatus = + normalizedSpans.length === 0 + ? ('empty' as const) + : normalizedSpans.some((span) => + span.type?.toLowerCase() === 'workflow' + ? hasUnhandledErrorInTree(span) + : hasErrorInTree(span) + ) + ? ('error' as const) + : ('success' as const) + + const handleSelect = useCallback((id: string) => setSelectedId(id), []) + + const handleToggleExpand = useCallback((id: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Ignore while typing in inputs / contentEditable (filter box, etc.). + const target = e.target as HTMLElement | null + if (target) { + const tag = target.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return + } + if (!selectedId) return + const currentIndex = flatList.findIndex((entry) => getSpanId(entry.span) === selectedId) + if (currentIndex === -1) return + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = flatList[Math.min(flatList.length - 1, currentIndex + 1)] + if (next) setSelectedId(getSpanId(next.span)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const prev = flatList[Math.max(0, currentIndex - 1)] + if (prev) setSelectedId(getSpanId(prev.span)) + } else if (e.key === 'ArrowLeft') { + const entry = flatList[currentIndex] + const span = entry.span + const id = getSpanId(span) + const canExpand = getDisplayChildren(span).length > 0 + if (canExpand && expandedNodes.has(id)) { + e.preventDefault() + handleToggleExpand(id) + } else if (entry.parentIds.length > 0) { + e.preventDefault() + const parentId = entry.parentIds[entry.parentIds.length - 1] + setSelectedId(parentId) + } + } else if (e.key === 'ArrowRight') { + const entry = flatList[currentIndex] + const span = entry.span + const id = getSpanId(span) + const canExpand = getDisplayChildren(span).length > 0 + if (canExpand && !expandedNodes.has(id)) { + e.preventDefault() + handleToggleExpand(id) + } + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [flatList, selectedId, expandedNodes, handleToggleExpand]) + + useEffect(() => { + if (!selectedId || !treeRef.current) return + const row = treeRef.current.querySelector( + `[data-span-id="${CSS.escape(selectedId)}"]` + ) + row?.scrollIntoView({ block: 'nearest' }) + }, [selectedId]) + + if (!traceSpans || traceSpans.length === 0) { + return ( +
+ No trace data available +
+ ) + } + + return ( +
+ {/* Header strip */} +
+ + {runStatus === 'error' ? 'Error' : 'Success'} + + {firstErrorId && ( + + )} + + {formatDuration(totalDuration, { precision: 2 }) || '—'} + + + {blockCount} {blockCount === 1 ? 'span' : 'spans'} + + {(() => { + const rootCost = formatCostAmount(normalizedSpans[0]?.cost?.total) + return rootCost ? ( + + {rootCost} + + ) : null + })()} +
+
+ + setSearchQuery(e.target.value)} + placeholder='Filter spans' + className='h-[24px] w-[140px] pl-[22px] text-caption' + /> +
+ + + + + Expand all + + + + + + Collapse all + +
+
+ + {/* Tree + detail split */} +
+
+ {flatList.length === 0 && ( +
No matching spans
+ )} + {flatList.map((entry) => { + const id = getSpanId(entry.span) + const canExpand = getDisplayChildren(entry.span).length > 0 + return ( + + ) + })} +
+ {/* Resize handle */} +
{ + isResizingRef.current = true + startXRef.current = e.clientX + startWidthRef.current = treePaneWidthRef.current + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + }} + > +
+
+
+ +
+
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 42e09dd0385..2be31bc5bcc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -1,22 +1,27 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import { formatDuration } from '@sim/utils/formatting' -import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react' +import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Eye, Search, X } from 'lucide-react' import { createPortal } from 'react-dom' import { Button, Code, + Copy as CopyIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, - Eye, Input, + Redo, + Search as SearchIcon, + SModalTabs, + SModalTabsContent, + SModalTabsList, + SModalTabsTrigger, Tooltip, } from '@/components/emcn' -import { Copy as CopyIcon, Search as SearchIcon } from '@/components/emcn/icons' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { cn } from '@/lib/core/utils/cn' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -24,7 +29,7 @@ import { workflowBorderColor } from '@/lib/workspaces/colors' import { ExecutionSnapshot, FileCards, - TraceSpans, + TraceView, } from '@/app/workspace/[workspaceId]/logs/components' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import { @@ -70,24 +75,20 @@ export const WorkflowOutputSection = memo( const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) - const handleContextMenu = useCallback((e: React.MouseEvent) => { + function handleContextMenu(e: React.MouseEvent) { e.preventDefault() e.stopPropagation() setContextMenuPosition({ x: e.clientX, y: e.clientY }) setIsContextMenuOpen(true) - }, []) - - const closeContextMenu = useCallback(() => { - setIsContextMenuOpen(false) - }, []) + } - const handleCopy = useCallback(() => { + function handleCopy() { navigator.clipboard.writeText(jsonString) setCopied(true) if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500) - closeContextMenu() - }, [jsonString, closeContextMenu]) + setIsContextMenuOpen(false) + } useEffect(() => { return () => { @@ -95,10 +96,10 @@ export const WorkflowOutputSection = memo( } }, []) - const handleSearch = useCallback(() => { + function handleSearch() { activateSearch() - closeContextMenu() - }, [activateSearch, closeContextMenu]) + setIsContextMenuOpen(false) + } return (
@@ -209,7 +210,11 @@ export const WorkflowOutputSection = memo( {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} {typeof document !== 'undefined' && createPortal( - + setIsContextMenuOpen(false)} + modal={false} + >
void + /** Whether a retry is currently in progress */ + isRetryPending?: boolean + /** Fires when the active tab changes, so the parent can gate its own keyboard handlers */ + onActiveTabChange?: (tab: LogDetailsTab) => void } /** @@ -272,6 +283,8 @@ interface LogDetailsProps { * @param props - Component props * @returns Log details sidebar component */ +type LogDetailsTab = 'overview' | 'trace' + export const LogDetails = memo(function LogDetails({ log, isOpen, @@ -280,9 +293,27 @@ export const LogDetails = memo(function LogDetails({ onNavigatePrev, hasNext = false, hasPrev = false, + onRetryExecution, + isRetryPending = false, + onActiveTabChange, }: LogDetailsProps) { const [isExecutionSnapshotOpen, setIsExecutionSnapshotOpen] = useState(false) + const [activeTab, setActiveTab] = useState('overview') + const [prevLogId, setPrevLogId] = useState(log?.id) + const [copiedRunId, setCopiedRunId] = useState(false) + + if (prevLogId !== log?.id) { + setPrevLogId(log?.id) + setActiveTab('overview') + } + const copiedRunIdTimerRef = useRef(null) const scrollAreaRef = useRef(null) + + useEffect(() => { + return () => { + if (copiedRunIdTimerRef.current !== null) window.clearTimeout(copiedRunIdTimerRef.current) + } + }, []) const panelWidth = useLogDetailsUIStore((state) => state.panelWidth) const { handleMouseDown } = useLogDetailsResize() const { config: permissionConfig } = usePermissionConfig() @@ -301,7 +332,22 @@ export const LogDetails = memo(function LogDetails({ ((log.trigger === 'manual' && !!log.duration) || !!(log.executionData?.enhanced && log.executionData?.traceSpans)) - const hasCostInfo = isWorkflowExecutionLog && log?.cost + const hasCostInfo = !!(isWorkflowExecutionLog && log?.cost) + const showWorkflowState = + isWorkflowExecutionLog && + !!log?.executionId && + log?.trigger !== 'mothership' && + !permissionConfig.hideTraceSpans + const showTraceTab = + isWorkflowExecutionLog && !!log?.executionData?.traceSpans && !permissionConfig.hideTraceSpans + + const resolvedTab: LogDetailsTab = activeTab === 'trace' && !showTraceTab ? 'overview' : activeTab + + const prevResolvedTabRef = useRef(resolvedTab) + if (prevResolvedTabRef.current !== resolvedTab) { + prevResolvedTabRef.current = resolvedTab + if (resolvedTab !== activeTab) onActiveTabChange?.(resolvedTab) + } const workflowOutput = useMemo(() => { const executionData = log?.executionData as @@ -311,28 +357,42 @@ export const LogDetails = memo(function LogDetails({ return filterHiddenOutputKeys(executionData.finalOutput) as Record }, [log?.executionData]) + const workflowInput = useMemo(() => { + const executionData = log?.executionData as { workflowInput?: unknown } | undefined + const raw = executionData?.workflowInput + if (raw === undefined || raw === null) return null + if (typeof raw === 'object' && !Array.isArray(raw)) { + return raw as Record + } + return { input: raw } as Record + }, [log?.executionData]) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { onClose() } - if (isOpen) { - if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) { - e.preventDefault() - onNavigatePrev() - } + if (!isOpen) return + + // When the Trace tab is active, arrow keys belong to TraceView's own + // span-navigation handler. Log-to-log navigation should not hijack them. + if (resolvedTab === 'trace') return - if (e.key === 'ArrowDown' && hasNext && onNavigateNext) { - e.preventDefault() - onNavigateNext() - } + if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) { + e.preventDefault() + onNavigatePrev() + } + + if (e.key === 'ArrowDown' && hasNext && onNavigateNext) { + e.preventDefault() + onNavigateNext() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext]) + }, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext, resolvedTab]) const formattedTimestamp = log ? formatDate(log.createdAt) : null @@ -343,7 +403,7 @@ export const LogDetails = memo(function LogDetails({ {/* Resize Handle - positioned outside the panel */} {isOpen && (
@@ -365,6 +426,24 @@ export const LogDetails = memo(function LogDetails({

Log Details

+ {log?.status === 'failed' && + (log?.workflow?.id || log?.workflowId) && + log?.trigger !== 'mothership' && ( + + + + + Retry + + )}
- {/* Content - Scrollable */} -
-
- {/* Timestamp & Workflow Row */} -
- {/* Timestamp Card */} -
-
- Timestamp -
-
- - {formattedTimestamp?.compactDate || 'N/A'} + {/* Tabs */} + { + const tab = v as LogDetailsTab + setActiveTab(tab) + onActiveTabChange?.(tab) + }} + className='mt-4 flex min-h-0 flex-1 flex-col' + > + + Overview + {showTraceTab && Trace} + + + {/* Overview Tab */} + +
+ {/* Timestamp + Workflow header */} +
+
+ + Timestamp - - {formattedTimestamp?.compactTime || 'N/A'} + + {formattedTimestamp + ? `${formattedTimestamp.compactDate} ${formattedTimestamp.compactTime}` + : '—'}
-
- - {/* Workflow Card */} -
-
- {log.trigger === 'mothership' ? 'Job' : 'Workflow'} -
-
- {(() => { - const c = - log.trigger === 'mothership' - ? '#ec4899' - : log.workflow?.color || - (!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined) - return ( -
- ) - })()} - - {log.trigger === 'mothership' - ? log.jobTitle || 'Untitled Job' - : log.workflow?.name || - (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} +
+ + {log.trigger === 'mothership' ? 'Job' : 'Workflow'} +
+ {(() => { + const c = + log.trigger === 'mothership' + ? '#ec4899' + : log.workflow?.color || + (!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined) + return ( +
+ ) + })()} + + {log.trigger === 'mothership' + ? log.jobTitle || 'Untitled Job' + : log.workflow?.name || + (!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')} + +
-
- - {/* Run ID */} - {log.executionId && ( -
- - Run ID - - - {log.executionId} - -
- )} - - {/* Details Section */} -
- {/* Level */} -
- - Level - - -
- {/* Trigger */} -
- - Trigger - - {log.trigger ? ( - - ) : ( - - — - + {/* Details Section */} +
+ {/* Run ID — click to copy */} + {log.executionId && ( +
{ + navigator.clipboard.writeText(log.executionId!) + if (copiedRunIdTimerRef.current) clearTimeout(copiedRunIdTimerRef.current) + setCopiedRunId(true) + copiedRunIdTimerRef.current = window.setTimeout( + () => setCopiedRunId(false), + 1500 + ) + }} + > + + Run ID + + + {copiedRunId ? 'Copied!' : log.executionId} + +
)} -
- {/* Duration */} -
- - Duration - - - {formatDuration(log.duration, { precision: 2 }) || '—'} - -
+ {/* Level */} +
+ + Level + + +
- {/* Version */} - {log.deploymentVersion && ( -
- - Version + {/* Trigger */} +
+ + Trigger -
- - {log.deploymentVersionName || `v${log.deploymentVersion}`} + {log.trigger ? ( + + ) : ( + + — -
+ )}
- )} -
- {/* Workflow State */} - {isWorkflowExecutionLog && - log.executionId && - log.trigger !== 'mothership' && - !permissionConfig.hideTraceSpans && ( -
+ {/* Duration */} +
- Workflow State + Duration + + + {formatDuration(log.duration, { precision: 2 }) || '—'} -
- )} - {/* Workflow Output */} - {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( -
- - Workflow Output - - + {/* Version */} + {log.deploymentVersion && ( +
+ + Version + +
+ + {log.deploymentVersionName || `v${log.deploymentVersion}`} + +
+
+ )} + + {/* Snapshot */} + {showWorkflowState && ( +
+ + Snapshot + + +
+ )}
- )} - {/* Workflow Execution - Trace Spans */} - {isWorkflowExecutionLog && - log.executionData?.traceSpans && - !permissionConfig.hideTraceSpans && ( -
+ {/* Workflow Input */} + {isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && ( +
- Trace Span + Workflow Input - +
)} - {/* Files */} - {log.files && log.files.length > 0 && ( - - )} - - {/* Cost Breakdown */} - {hasCostInfo && ( -
- - Cost Breakdown - - -
-
-
- - Base Run: - - - {formatCost(BASE_EXECUTION_CHARGE)} - -
-
- - Model Input: - - - {formatCost(log.cost?.input || 0)} - -
-
- - Model Output: - - - {formatCost(log.cost?.output || 0)} - -
- {(() => { - const models = (log.cost as Record)?.models as - | Record - | undefined - const totalToolCost = models - ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) - : 0 - return totalToolCost > 0 ? ( -
- - Tool Usage: - - - {formatCost(totalToolCost)} - -
- ) : null - })()} -
+ {/* Workflow Output */} + {isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && ( +
+ + Workflow Output + + +
+ )} -
+ {/* Files */} + {log.files && log.files.length > 0 && ( + + )} -
-
- - Total: - - - {formatCost(log.cost?.total || 0)} - -
-
- - Tokens: - - - {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '} - {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out - -
+ {/* Cost Breakdown */} + {hasCostInfo && ( +
+
+ + Base Run + + + {formatCost(BASE_EXECUTION_CHARGE)} + +
+
+ + Model Input + + + {formatCost(log.cost?.input || 0)} + +
+
+ + Model Output + + + {formatCost(log.cost?.output || 0)} + +
+ {(() => { + const models = (log.cost as Record)?.models as + | Record + | undefined + const totalToolCost = models + ? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0) + : 0 + return totalToolCost > 0 ? ( +
+ + Tool Usage + + + {formatCost(totalToolCost)} + +
+ ) : null + })()} +
+ + Total + + + {formatCost(log.cost?.total || 0)} + +
+
+ + Tokens + + + {log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in ·{' '} + {log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out + +
+
+

+ Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus + model and tool usage. +

+ )} +
+ -
-

- Total cost includes a base run charge of {formatCost(BASE_EXECUTION_CHARGE)}{' '} - plus any model and tool usage costs. -

-
-
- )} -
-
+ {/* Trace Tab */} + {showTraceTab && log.executionData?.traceSpans && ( + + + + )} +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index a9dba9f471d..0ce7aafa9f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -2,13 +2,19 @@ import { memo } from 'react' import { + Copy, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + Eye, + Link, + ListFilter, + Redo, + SquareArrowUpRight, + X, } from '@/components/emcn' -import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons' import type { WorkflowLog } from '@/stores/logs/filters/types' interface LogRowContextMenuProps { @@ -23,6 +29,8 @@ interface LogRowContextMenuProps { onToggleWorkflowFilter: () => void onClearAllFilters: () => void onCancelExecution: () => void + onRetryExecution: () => void + isRetryPending?: boolean isFilteredByThisWorkflow: boolean hasActiveFilters: boolean } @@ -43,6 +51,8 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ onToggleWorkflowFilter, onClearAllFilters, onCancelExecution, + onRetryExecution, + isRetryPending = false, isFilteredByThisWorkflow, hasActiveFilters, }: LogRowContextMenuProps) { @@ -50,6 +60,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId) const isCancellable = (log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow + const isRetryable = log?.status === 'failed' && hasWorkflow && log?.trigger !== 'mothership' return ( !open && onClose()} modal={false}> @@ -73,6 +84,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > + {isRetryable && ( + <> + + + {isRetryPending ? 'Retrying...' : 'Retry'} + + + + )} {isCancellable && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx index 2aad8a647b1..5dc4025b2f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx @@ -44,7 +44,7 @@ const LogRow = memo( onContextMenu, selectedRowRef, }: LogRowProps) { - const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt]) + const formattedDate = formatDate(log.createdAt) const isMothershipJob = log.trigger === 'mothership' const isDeletedWorkflow = !isMothershipJob && !log.workflow?.id && !log.workflowId const workflowName = isMothershipJob diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index b895f447b57..193a4cde105 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { Search, X } from 'lucide-react' import { useParams } from 'next/navigation' -import { Badge } from '@/components/emcn' +import { Badge, Button } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser' @@ -233,13 +233,14 @@ export function AutocompleteSearch({ {/* Clear All Button */} {(hasFilters || hasTextSearch) && ( - + )}
@@ -248,7 +249,7 @@ export function AutocompleteSearch({ {/* Show all results (no header) */} {suggestions[0]?.category === 'show-all' && ( - + )} {sections.map((section) => ( @@ -289,11 +291,12 @@ export function AutocompleteSearch({ const isHighlighted = index === highlightedIndex return ( -
- + ) })}
@@ -332,11 +335,12 @@ export function AutocompleteSearch({ )} {suggestions.map((suggestion, index) => ( -
- + ))}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index 501d208fb87..53494a181dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -9,13 +9,13 @@ import { Button, Combobox, type ComboboxOption, + DatePicker, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Loader, } from '@/components/emcn' -import { DatePicker } from '@/components/emcn/components/date-picker/date-picker' import { cn } from '@/lib/core/utils/cn' import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' @@ -397,28 +397,25 @@ export const LogsToolbar = memo(function LogsToolbar({ /** * Handles date range selection from DatePicker. */ - const handleDateRangeApply = useCallback( - (start: string, end: string) => { - dateRangeAppliedRef.current = true - setDateRange(start, end) - setDatePickerOpen(false) - captureEvent(posthogRef.current, 'logs_filter_applied', { - filter_type: 'time', - workspace_id: workspaceId, - }) - }, - [setDateRange, workspaceId] - ) + function handleDateRangeApply(start: string, end: string) { + dateRangeAppliedRef.current = true + setDateRange(start, end) + setDatePickerOpen(false) + captureEvent(posthogRef.current, 'logs_filter_applied', { + filter_type: 'time', + workspace_id: workspaceId, + }) + } /** * Handles date picker cancel. */ - const handleDatePickerCancel = useCallback(() => { + function handleDatePickerCancel() { if (timeRange === 'Custom range' && !startDate) { setTimeRange(previousTimeRange) } setDatePickerOpen(false) - }, [timeRange, startDate, previousTimeRange, setTimeRange]) + } const filtersActive = useMemo( () => @@ -433,10 +430,10 @@ export const LogsToolbar = memo(function LogsToolbar({ [timeRange, level, workflowIds, folderIds, triggers, searchQuery] ) - const handleClearFilters = useCallback(() => { + function handleClearFilters() { resetFilters() onSearchQueryChange('') - }, [resetFilters, onSearchQueryChange]) + } return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index dc2e188432c..f37e7b444ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -10,11 +10,12 @@ import { Button, Combobox, type ComboboxOption, + DatePicker, Download, Library, RefreshCw, + toast, } from '@/components/emcn' -import { DatePicker } from '@/components/emcn/components/date-picker/date-picker' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { cn } from '@/lib/core/utils/cn' import { @@ -50,11 +51,14 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { getBlock } from '@/blocks/registry' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { + fetchLogDetail, + logKeys, prefetchLogDetail, useCancelExecution, useDashboardStats, useLogDetail, useLogsList, + useRetryExecution, } from '@/hooks/queries/logs' import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' @@ -71,6 +75,7 @@ import { import { DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_LABEL, + extractRetryInput, formatDate, getDisplayStatus, type LogStatus, @@ -285,8 +290,10 @@ export default function Logs() { const logsRef = useRef([]) const selectedLogIndexRef = useRef(-1) const selectedLogIdRef = useRef(null) + const shouldScrollIntoViewRef = useRef(false) const logsRefetchRef = useRef<() => void>(() => {}) const activeLogRefetchRef = useRef<() => void>(() => {}) + const activeLogTabRef = useRef('overview') const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const [activeSort, setActiveSort] = useState<{ @@ -299,23 +306,17 @@ export default function Logs() { const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) const [contextMenuLog, setContextMenuLog] = useState(null) - const [isPreviewOpen, setIsPreviewOpen] = useState(false) const [previewLogId, setPreviewLogId] = useState(null) - const activeLogId = isPreviewOpen ? previewLogId : selectedLogId + const activeLogId = previewLogId ?? selectedLogId const queryClient = useQueryClient() - const detailRefetchInterval = useCallback( - (query: { state: { data?: WorkflowLog } }) => { + const activeLogQuery = useLogDetail(activeLogId ?? undefined, { + refetchInterval: (query: { state: { data?: WorkflowLog } }) => { if (!isLive) return false const status = query.state.data?.status return status === 'running' || status === 'pending' ? 3000 : false }, - [isLive] - ) - - const activeLogQuery = useLogDetail(activeLogId ?? undefined, { - refetchInterval: detailRefetchInterval, }) const logFilters = useMemo( @@ -393,18 +394,14 @@ export default function Logs() { }) }, [logs, activeSort]) - const selectedLogIndex = useMemo( - () => (selectedLogId ? sortedLogs.findIndex((l) => l.id === selectedLogId) : -1), - [sortedLogs, selectedLogId] - ) + const selectedLogIndex = selectedLogId ? sortedLogs.findIndex((l) => l.id === selectedLogId) : -1 const selectedLogFromList = selectedLogIndex >= 0 ? sortedLogs[selectedLogIndex] : null const selectedLog = useMemo(() => { if (!selectedLogFromList) return null - if (!activeLogQuery.data || isPreviewOpen || activeLogQuery.isPlaceholderData) - return selectedLogFromList + if (!activeLogQuery.data || previewLogId !== null) return selectedLogFromList return { ...selectedLogFromList, ...activeLogQuery.data } - }, [selectedLogFromList, activeLogQuery.data, activeLogQuery.isPlaceholderData, isPreviewOpen]) + }, [selectedLogFromList, activeLogQuery.data, previewLogId]) const handleLogHover = useCallback( (rowId: string) => { @@ -462,6 +459,7 @@ export default function Logs() { const idx = selectedLogIndexRef.current const currentLogs = logsRef.current if (idx < currentLogs.length - 1) { + shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id }) } }, []) @@ -469,12 +467,18 @@ export default function Logs() { const handleNavigatePrev = useCallback(() => { const idx = selectedLogIndexRef.current if (idx > 0) { + shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id }) } }, []) const handleCloseSidebar = useCallback(() => { dispatch({ type: 'CLOSE_SIDEBAR' }) + activeLogTabRef.current = 'overview' + }, []) + + const handleActiveTabChange = useCallback((tab: string) => { + activeLogTabRef.current = tab }, []) const handleLogContextMenu = useCallback( @@ -527,11 +531,11 @@ export default function Logs() { const handleOpenPreview = useCallback(() => { if (contextMenuLog?.id) { setPreviewLogId(contextMenuLog.id) - setIsPreviewOpen(true) } }, [contextMenuLog]) const cancelExecution = useCancelExecution() + const retryExecution = useRetryExecution() const handleCancelExecution = useCallback(() => { const workflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId @@ -542,6 +546,37 @@ export default function Logs() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [contextMenuLog]) + const retryLog = useCallback( + async (log: WorkflowLog | null) => { + const workflowId = log?.workflow?.id || log?.workflowId + const logId = log?.id + if (!workflowId || !logId) return + + try { + const detailLog = await queryClient.fetchQuery({ + queryKey: logKeys.detail(logId), + queryFn: ({ signal }) => fetchLogDetail(logId, signal), + staleTime: 30 * 1000, + }) + const input = extractRetryInput(detailLog) + await retryExecution.mutateAsync({ workflowId, input }) + toast.success('Retry started') + } catch { + toast.error('Failed to retry execution') + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + const handleRetryExecution = useCallback(() => { + retryLog(contextMenuLog) + }, [contextMenuLog, retryLog]) + + const handleRetrySidebarExecution = useCallback(() => { + retryLog(selectedLog) + }, [selectedLog, retryLog]) + const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId const isFilteredByThisWorkflow = Boolean( contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId @@ -557,7 +592,8 @@ export default function Logs() { }) useEffect(() => { - if (!selectedLogId) return + if (!selectedLogId || !shouldScrollIntoViewRef.current) return + shouldScrollIntoViewRef.current = false const row = document.querySelector(`[data-row-id="${selectedLogId}"]`) as HTMLElement | null if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) @@ -670,12 +706,14 @@ export default function Logs() { const handleKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (activeLogTabRef.current === 'trace') return const currentLogs = logsRef.current const currentIndex = selectedLogIndexRef.current if (currentLogs.length === 0) return if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault() + shouldScrollIntoViewRef.current = true dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id }) return } @@ -707,10 +745,9 @@ export default function Logs() { const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), []) const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), []) - const handleClosePreview = useCallback(() => { - setIsPreviewOpen(false) + function handleClosePreview() { setPreviewLogId(null) - }, []) + } const isDashboardView = viewMode === 'dashboard' @@ -770,27 +807,19 @@ export default function Logs() { [sortedLogs] ) - const sidebarOverlay = useMemo( - () => ( - 0} - /> - ), - [ - selectedLog, - effectiveSidebarOpen, - handleCloseSidebar, - handleNavigateNext, - handleNavigatePrev, - selectedLogIndex, - sortedLogs.length, - ] + const sidebarOverlay = ( + 0} + onRetryExecution={handleRetrySidebarExecution} + isRetryPending={retryExecution.isPending} + onActiveTabChange={handleActiveTabChange} + /> ) const { data: allWorkflows = {} } = useWorkflowMap(workspaceId) @@ -923,23 +952,17 @@ export default function Logs() { getSuggestions, }) - const lastExternalSearchValue = useRef(searchQuery) + const lastExternalSearchValue = useRef(undefined) useEffect(() => { - if (searchQuery !== lastExternalSearchValue.current) { - lastExternalSearchValue.current = searchQuery - const parsed = parseQuery(searchQuery) - initializeFromQuery(parsed.textSearch, parsed.filters) - } + if (searchQuery === lastExternalSearchValue.current) return + const isMount = lastExternalSearchValue.current === undefined + lastExternalSearchValue.current = searchQuery + // On mount with no initial query, skip the no-op parse + if (isMount && !searchQuery) return + const parsed = parseQuery(searchQuery) + initializeFromQuery(parsed.textSearch, parsed.filters) }, [searchQuery, initializeFromQuery]) - useEffect(() => { - if (searchQuery) { - const parsed = parseQuery(searchQuery) - initializeFromQuery(parsed.textSearch, parsed.filters) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - useEffect(() => { if (!isSuggestionsOpen || highlightedIndex < 0) return const container = searchDropdownRef.current @@ -1193,18 +1216,20 @@ export default function Logs() { onOpenWorkflow={handleOpenWorkflow} onOpenPreview={handleOpenPreview} onCancelExecution={handleCancelExecution} + onRetryExecution={handleRetryExecution} + isRetryPending={retryExecution.isPending} onToggleWorkflowFilter={handleToggleWorkflowFilter} onClearAllFilters={handleClearAllFilters} isFilteredByThisWorkflow={isFilteredByThisWorkflow} hasActiveFilters={filtersActive} /> - {isPreviewOpen && !activeLogQuery.isPlaceholderData && activeLogQuery.data?.executionId && ( + {previewLogId !== null && activeLogQuery.data?.executionId && ( )} @@ -1258,7 +1283,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr ) const [datePickerOpen, setDatePickerOpen] = useState(false) - const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) + const previousTimeRangeRef = useRef(timeRange) const dateRangeAppliedRef = useRef(false) const { data: folders = {} } = useFolderMap(workspaceId) const { data: allWorkflowList = [] } = useWorkflows(workspaceId) @@ -1349,7 +1374,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr const handleTimeRangeChange = (val: string) => { if (val === 'Custom range') { - setPreviousTimeRange(timeRange) + previousTimeRangeRef.current = timeRange setDatePickerOpen(true) } else { clearDateRange() @@ -1365,7 +1390,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr const handleDatePickerCancel = () => { if (timeRange === 'Custom range' && !startDate) { - setTimeRange(previousTimeRange) + setTimeRange(previousTimeRangeRef.current) } setDatePickerOpen(false) } @@ -1548,10 +1573,12 @@ function SuggestionButton({ showCategory?: boolean }) { return ( - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 535ab8000d9..bfca8a90b5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -4,6 +4,7 @@ import { format } from 'date-fns' import { Badge } from '@/components/emcn' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' +import type { WorkflowLog } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' export const LOG_COLUMNS = { @@ -442,3 +443,37 @@ export const formatDate = (dateString: string) => { })(), } } + +/** + * Extracts the original workflow input from a log entry for retry. + * Prefers the persisted `workflowInput` field (new logs), falls back to + * reconstructing from `executionState.blockStates` (old logs). + */ +export function extractRetryInput(log: WorkflowLog): unknown | undefined { + const execData = log.executionData as Record | undefined + if (!execData) return undefined + + if (execData.workflowInput !== undefined) { + return execData.workflowInput + } + + const executionState = execData.executionState as + | { + blockStates?: Record< + string, + { output?: unknown; executed?: boolean; executionTime?: number } + > + } + | undefined + if (!executionState?.blockStates) return undefined + + // Starter/trigger blocks are pre-populated with executed: false and + // executionTime: 0, which distinguishes them from blocks that actually ran. + for (const state of Object.values(executionState.blockStates)) { + if (state.executed === false && state.executionTime === 0 && state.output != null) { + return state.output + } + } + + return undefined +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx index 42cda8def1c..fd04a8e7b3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx @@ -22,6 +22,7 @@ import { Textarea, Tooltip, Trash, + toast, } from '@/components/emcn' import { Input } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' @@ -60,7 +61,6 @@ const logger = createLogger('SecretsManager') const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto_auto] items-center' const COL_SPAN_ALL = 'col-span-5' -const CONFLICT_CLASS = 'border-[var(--text-error)] bg-[var(--error-muted)]' const ROLE_OPTIONS = [ { value: 'member', label: 'Member' }, @@ -402,7 +402,6 @@ export function CredentialsManager() { const isWorkspaceAdmin = workspacePermissions?.viewer?.isAdmin ?? false const isLoading = isPersonalLoading || isWorkspaceLoading - const variables = useMemo(() => personalEnvData || {}, [personalEnvData]) const [envVars, setEnvVars] = useState([]) const [newWorkspaceRows, setNewWorkspaceRows] = useState([ @@ -414,8 +413,6 @@ export function CredentialsManager() { const [workspaceVars, setWorkspaceVars] = useState>({}) const [renamingKey, setRenamingKey] = useState(null) const [pendingKeyValue, setPendingKeyValue] = useState('') - const [changeToken, setChangeToken] = useState(0) - const [selectedCredentialId, setSelectedCredentialId] = useState(null) const [prevSelectedCredentialId, setPrevSelectedCredentialId] = useState< string | null | undefined @@ -432,7 +429,8 @@ export function CredentialsManager() { const scrollContainerRef = useRef(null) const initialVarsRef = useRef([]) const hasChangesRef = useRef(false) - const hasSavedRef = useRef(false) + const hasSavedPersonalRef = useRef(false) + const hasSavedWorkspaceRef = useRef(false) const shouldBlockNavRef = useRef(false) const pendingNavigationUrlRef = useRef(null) @@ -559,7 +557,7 @@ export function CredentialsManager() { if (newWorkspaceRows.some((row) => row.key && row.value)) return true return false - }, [envVars, workspaceVars, newWorkspaceRows, changeToken]) + }, [envVars, workspaceVars, newWorkspaceRows]) const hasConflicts = useMemo(() => { return envVars.some((envVar) => !!envVar.key && allWorkspaceKeys.has(envVar.key)) @@ -589,9 +587,12 @@ export function CredentialsManager() { useEffect(() => () => resetNavGuard(), [resetNavGuard]) useEffect(() => { - if (hasSavedRef.current) return + if (hasSavedPersonalRef.current) { + hasSavedPersonalRef.current = false + return + } - const existingVars = Object.values(variables) + const existingVars = Object.values(personalEnvData || {}) const initialVars = [ ...existingVars.map((envVar) => ({ ...envVar, @@ -601,16 +602,16 @@ export function CredentialsManager() { ] initialVarsRef.current = JSON.parse(JSON.stringify(initialVars)) setEnvVars(JSON.parse(JSON.stringify(initialVars))) - }, [variables]) + }, [personalEnvData]) useEffect(() => { if (!workspaceEnvData) return - if (hasSavedRef.current) { - hasSavedRef.current = false - } else { - setWorkspaceVars(workspaceEnvData.workspace || {}) - initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {} + if (hasSavedWorkspaceRef.current) { + hasSavedWorkspaceRef.current = false + return } + setWorkspaceVars(workspaceEnvData.workspace || {}) + initialWorkspaceVarsRef.current = workspaceEnvData.workspace || {} }, [workspaceEnvData]) const scrollToBottom = useCallback(() => { @@ -968,84 +969,88 @@ export function CredentialsManager() { const handleSave = async () => { if (isListSaving) return - const prevInitialVars = [...initialVarsRef.current] - const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current } const mutations: Promise[] = [] - try { - setShowUnsavedChanges(false) - hasSavedRef.current = true - - const mergedWorkspaceVars = { ...workspaceVars } - for (const row of newWorkspaceRows) { - if (row.key && row.value) { - mergedWorkspaceVars[row.key] = row.value - } + setShowUnsavedChanges(false) + + const mergedWorkspaceVars = { ...workspaceVars } + for (const row of newWorkspaceRows) { + if (row.key && row.value) { + mergedWorkspaceVars[row.key] = row.value } + } - initialWorkspaceVarsRef.current = { ...mergedWorkspaceVars } - initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value))) + const validVariables = envVars + .filter((v) => v.key && v.value) + .reduce>((acc, { key, value }) => ({ ...acc, [key]: value }), {}) - setChangeToken((prev) => prev + 1) + const before = initialWorkspaceVarsRef.current + const after = mergedWorkspaceVars + const toUpsert: Record = {} + const toDelete: string[] = [] - const validVariables = envVars - .filter((v) => v.key && v.value) - .reduce>((acc, { key, value }) => ({ ...acc, [key]: value }), {}) + for (const [k, v] of Object.entries(after)) { + if (!(k in before) || before[k] !== v) { + toUpsert[k] = v + } + } - const before = prevInitialWorkspaceVars - const after = mergedWorkspaceVars - const toUpsert: Record = {} - const toDelete: string[] = [] + for (const k of Object.keys(before)) { + if (!(k in after)) toDelete.push(k) + } - for (const [k, v] of Object.entries(after)) { - if (!(k in before) || before[k] !== v) { - toUpsert[k] = v - } + const personalChanged = (() => { + const initialMap = new Map( + initialVarsRef.current.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) + ) + const currentKeys = Object.keys(validVariables) + if (initialMap.size !== currentKeys.length) return true + for (const [key, value] of Object.entries(validVariables)) { + if (initialMap.get(key) !== value) return true } + return false + })() - for (const k of Object.keys(before)) { - if (!(k in after)) toDelete.push(k) - } + const workspaceChanged = + workspaceId && (Object.keys(toUpsert).length > 0 || toDelete.length > 0) - const personalChanged = (() => { - const initialMap = new Map( - prevInitialVars.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) - ) - const currentKeys = Object.keys(validVariables) - if (initialMap.size !== currentKeys.length) return true - for (const [key, value] of Object.entries(validVariables)) { - if (initialMap.get(key) !== value) return true - } - return false - })() - - if (personalChanged) { - mutations.push(savePersonalMutation.mutateAsync({ variables: validVariables })) - } - if (workspaceId && (Object.keys(toUpsert).length || toDelete.length)) { - mutations.push( - (async () => { - if (Object.keys(toUpsert).length) { - await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert }) - } - if (toDelete.length) { - await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete }) - } - })() - ) - } + if (personalChanged) { + mutations.push(savePersonalMutation.mutateAsync({ variables: validVariables })) + } + if (workspaceChanged) { + mutations.push( + (async () => { + if (Object.keys(toUpsert).length) { + await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert }) + } + if (toDelete.length) { + await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete }) + } + })() + ) + } + + hasSavedPersonalRef.current = personalChanged + hasSavedWorkspaceRef.current = Boolean(workspaceChanged) + try { const results = await Promise.allSettled(mutations) const firstFailure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected') if (firstFailure) throw firstFailure.reason + initialWorkspaceVarsRef.current = { ...mergedWorkspaceVars } + initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value))) + setWorkspaceVars(mergedWorkspaceVars) setNewWorkspaceRows([createEmptyEnvVar()]) + if (mutations.length > 0) { + toast.success('Secrets saved') + } } catch (error) { - hasSavedRef.current = false - initialVarsRef.current = prevInitialVars - initialWorkspaceVarsRef.current = prevInitialWorkspaceVars + hasSavedPersonalRef.current = false + hasSavedWorkspaceRef.current = false logger.error('Failed to save environment variables:', error) + toast.error('Failed to save secrets') } finally { if (mutations.length > 0) { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }) @@ -1095,7 +1100,7 @@ export function CredentialsManager() { onFocus={(e) => e.target.removeAttribute('readOnly')} className={cn( 'h-9', - isConflict && CONFLICT_CLASS, + isConflict && 'border-[var(--text-error)]', keyError && 'border-[var(--text-error)]' )} /> @@ -1115,8 +1120,6 @@ export function CredentialsManager() { onBlur={() => setFocusedValueIndex(null)} onPaste={(e) => handlePaste(e, originalIndex)} placeholder={isConflict ? 'Workspace override active' : 'Enter value'} - disabled={isConflict} - aria-disabled={isConflict} name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`} autoComplete='off' autoCapitalize='off' @@ -1125,12 +1128,11 @@ export function CredentialsManager() { style={maskedValueStyle} className={cn( 'h-9', - !isComplete && 'col-span-2', - isConflict && 'cursor-not-allowed', - isConflict && CONFLICT_CLASS + (!isComplete || isConflict) && 'col-span-2', + isConflict && 'cursor-not-allowed opacity-50' )} /> - {isComplete && ( + {isComplete && !isConflict && (
{detailsError && ( -
+
{detailsError}
)} @@ -1299,7 +1301,7 @@ export function CredentialsManager() {
-

+

{member.userName || member.userEmail || member.userId}

diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx index 5fbe4188648..ab3c2c50e2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx @@ -801,15 +801,16 @@ export function IntegrationsManager() { {filteredServices.map((service) => { const config = getServiceConfigByProviderId(service.value) return ( - + ) })} {filteredServices.length === 0 && ( @@ -844,17 +845,18 @@ export function IntegrationsManager() { <>

- + Connect{' '} {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)} @@ -970,17 +972,18 @@ export function IntegrationsManager() { <>
- + Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)} @@ -1146,10 +1149,10 @@ export function IntegrationsManager() { ? This action cannot be undone.

{deleteError && ( -
+
- -

{deleteError}

+ +

{deleteError}

)} @@ -1240,9 +1243,10 @@ export function IntegrationsManager() { Display Name - + {copyIdSuccess ? 'Copied!' : 'Copy credential ID'} @@ -1288,7 +1292,7 @@ export function IntegrationsManager() {
{detailsError && ( -
+
{detailsError}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx index 97b7f1d15b8..a6f8ed631a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx @@ -1135,12 +1135,15 @@ function TeamPlanModal({ open, onOpenChange, isAnnual, onConfirm }: TeamPlanModa const [selectedTier, setSelectedTier] = useState(PRO_TIER.credits) const [selectedSeats, setSelectedSeats] = useState(1) - useEffect(() => { + // Reset selections each time the modal opens. + const prevOpenRef = useRef(open) + if (prevOpenRef.current !== open) { + prevOpenRef.current = open if (open) { setSelectedTier(PRO_TIER.credits) setSelectedSeats(1) } - }, [open]) + } const tier = CREDIT_TIERS.find((t) => t.credits === selectedTier) ?? PRO_TIER const monthlyCostPerSeat = tier.dollars @@ -1293,9 +1296,12 @@ function ManagePlanModal({ const [isSwitching, setIsSwitching] = useState(false) const [error, setError] = useState(null) - useEffect(() => { + // Clear the error each time the modal opens. + const prevOpenRef = useRef(open) + if (prevOpenRef.current !== open) { + prevOpenRef.current = open if (open) setError(null) - }, [open]) + } const isOnMax = currentPlanCredits === MAX_TIER.credits || (isLegacyPlan && isTeamPlan) const currentTier = isOnMax ? MAX_TIER : PRO_TIER diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 346fc336a2e..7f28b5bc246 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -251,11 +251,11 @@ export function Table({ const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId }) const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) - const handleColumnOrderChange = useCallback((order: string[]) => { + function handleColumnOrderChange(order: string[]) { setColumnOrder(order) - }, []) + } - const handleColumnRename = useCallback((oldName: string, newName: string) => { + function handleColumnRename(oldName: string, newName: string) { let updatedWidths = columnWidthsRef.current if (oldName in updatedWidths) { const { [oldName]: width, ...rest } = updatedWidths @@ -268,13 +268,15 @@ export function Table({ columnWidths: updatedWidths, ...(updatedOrder ? { columnOrder: updatedOrder } : {}), }) - }, []) + } - const getColumnWidths = useCallback(() => columnWidthsRef.current, []) + function getColumnWidths() { + return columnWidthsRef.current + } - const handleColumnWidthsChange = useCallback((widths: Record) => { + function handleColumnWidthsChange(widths: Record) { setColumnWidths(widths) - }, []) + } const { pushUndo, undo, redo } = useTableUndo({ workspaceId, @@ -856,7 +858,7 @@ export function Table({ setDropTargetColumnName(null) }, []) - const handleScrollDragOver = useCallback((e: React.DragEvent) => { + function handleScrollDragOver(e: React.DragEvent) { if (!dragColumnNameRef.current) return e.preventDefault() e.dataTransfer.dropEffect = 'move' @@ -881,11 +883,11 @@ export function Table({ } left += w } - }, []) + } - const handleScrollDrop = useCallback((e: React.DragEvent) => { + function handleScrollDrop(e: React.DragEvent) { e.preventDefault() - }, []) + } useEffect(() => { if (!tableData?.metadata || metadataSeededRef.current) return @@ -3211,36 +3213,30 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ [onDragLeave] ) - const handleHeaderClick = useCallback( - (e: React.MouseEvent) => { - if (didDragRef.current) { - didDragRef.current = false - return - } - if (isRenaming) return - onColumnSelect(colIndex, e.shiftKey) - }, - [colIndex, isRenaming, onColumnSelect] - ) + function handleHeaderClick(e: React.MouseEvent) { + if (didDragRef.current) { + didDragRef.current = false + return + } + if (isRenaming) return + onColumnSelect(colIndex, e.shiftKey) + } - const handleChevronClick = useCallback((e: React.MouseEvent) => { + function handleChevronClick(e: React.MouseEvent) { e.stopPropagation() const rect = (e.currentTarget as HTMLElement).closest('th')?.getBoundingClientRect() if (rect) { setMenuPosition({ x: rect.left, y: rect.bottom }) } setMenuOpen(true) - }, []) + } - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - if (readOnly || isRenaming) return - e.preventDefault() - setMenuPosition({ x: e.clientX, y: e.clientY }) - setMenuOpen(true) - }, - [readOnly, isRenaming] - ) + function handleContextMenu(e: React.MouseEvent) { + if (readOnly || isRenaming) return + e.preventDefault() + setMenuPosition({ x: e.clientX, y: e.clientY }) + setMenuOpen(true) + } return (
(null) const importMutation = useImportCsvIntoTable() - const resetState = useCallback(() => { + function resetState() { setParsed(null) setParseError(null) setSubmitError(null) @@ -116,15 +116,18 @@ export function ImportCsvDialog({ setIsDragging(false) setParsing(false) if (fileInputRef.current) fileInputRef.current.value = '' - }, []) + } - useEffect(() => { - if (!open) resetState() - }, [open, resetState]) + function handleOpenChange(newOpen: boolean) { + if (!newOpen) resetState() + onOpenChange(newOpen) + } - useEffect(() => { + const prevTableIdRef = useRef(table.id) + if (prevTableIdRef.current !== table.id) { + prevTableIdRef.current = table.id resetState() - }, [table.id, resetState]) + } const columnOptions: ComboboxOption[] = useMemo(() => { const options: ComboboxOption[] = [{ label: 'Do not import', value: SKIP_VALUE }] @@ -137,82 +140,73 @@ export function ImportCsvDialog({ return options }, [table.schema.columns]) - const handleFileSelected = useCallback( - async (file: File) => { - const ext = file.name.split('.').pop()?.toLowerCase() - if (ext !== 'csv' && ext !== 'tsv') { - setParseError('Only CSV and TSV files are supported') - return - } - setParsing(true) - setParseError(null) - try { - const arrayBuffer = await file.arrayBuffer() - const delimiter = ext === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter) - const autoMapping = buildAutoMapping(headers, table.schema) - setParsed({ - file, - headers, - sampleRows: rows.slice(0, MAX_SAMPLE_ROWS), - totalRows: rows.length, - }) - setMapping(autoMapping) - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to parse CSV' - logger.error('CSV parse failed', err) - setParseError(message) - } finally { - setParsing(false) - } - }, - [table.schema] - ) + async function handleFileSelected(file: File) { + const ext = file.name.split('.').pop()?.toLowerCase() + if (ext !== 'csv' && ext !== 'tsv') { + setParseError('Only CSV and TSV files are supported') + return + } + setParsing(true) + setParseError(null) + try { + const arrayBuffer = await file.arrayBuffer() + const delimiter = ext === 'tsv' ? '\t' : ',' + const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter) + const autoMapping = buildAutoMapping(headers, table.schema) + setParsed({ + file, + headers, + sampleRows: rows.slice(0, MAX_SAMPLE_ROWS), + totalRows: rows.length, + }) + setMapping(autoMapping) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to parse CSV' + logger.error('CSV parse failed', err) + setParseError(message) + } finally { + setParsing(false) + } + } - const handleFileInputChange = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) void handleFileSelected(file) - }, - [handleFileSelected] - ) + function handleFileInputChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (file) void handleFileSelected(file) + } - const handleDragEnter = useCallback((e: React.DragEvent) => { + function handleDragEnter(e: React.DragEvent) { e.preventDefault() setIsDragging(true) - }, []) + } - const handleDragOver = useCallback((e: React.DragEvent) => { + function handleDragOver(e: React.DragEvent) { e.preventDefault() - }, []) + } - const handleDragLeave = useCallback((e: React.DragEvent) => { + function handleDragLeave(e: React.DragEvent) { e.preventDefault() setIsDragging(false) - }, []) - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - setIsDragging(false) - const file = e.dataTransfer.files?.[0] - if (file) void handleFileSelected(file) - }, - [handleFileSelected] - ) + } - const handleMappingChange = useCallback((header: string, value: string) => { + function handleDrop(e: React.DragEvent) { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) void handleFileSelected(file) + } + + function handleMappingChange(header: string, value: string) { setSubmitError(null) setMapping((prev) => ({ ...prev, [header]: value === SKIP_VALUE ? null : value, })) - }, []) + } - const handleModeChange = useCallback((value: string) => { + function handleModeChange(value: string) { setSubmitError(null) setMode(value as CsvImportMode) - }, []) + } const { missingRequired, duplicateTargets, mappedCount, skipCount } = useMemo(() => { const mappedTargets = new Map() @@ -263,7 +257,7 @@ export function ImportCsvDialog({ appendCapacityDeficit === 0 && replaceCapacityDeficit === 0 - const handleSubmit = useCallback(async () => { + async function handleSubmit() { if (!parsed || !canSubmit) return setSubmitError(null) try { @@ -292,18 +286,7 @@ export function ImportCsvDialog({ setSubmitError(summarizeImportError(message)) logger.error('CSV import into existing table failed', err) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - canSubmit, - mapping, - mode, - onImported, - onOpenChange, - parsed, - table.id, - table.name, - workspaceId, - ]) + } const hasWarning = missingRequired.length > 0 || @@ -312,7 +295,7 @@ export function ImportCsvDialog({ replaceCapacityDeficit > 0 return ( - + Import CSV into {table.name} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 37873145533..449d1900338 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { X } from 'lucide-react' import { useParams } from 'next/navigation' @@ -69,39 +69,34 @@ function SingleFileSelector({ isDeleting, }: SingleFileSelectorProps) { const displayLabel = `${truncateMiddle(file.name, 20, 12)} (${formatFileSize(file.size)})` - const [localInputValue, setLocalInputValue] = useState(displayLabel) + const [searchQuery, setSearchQuery] = useState('') const [isEditing, setIsEditing] = useState(false) - - // Sync display label when file changes - useEffect(() => { - if (!isEditing) { - setLocalInputValue(displayLabel) - } - }, [displayLabel, isEditing]) + // When not editing, always show the file's display label. When editing, show the user's query. + const comboboxValue = isEditing ? searchQuery : displayLabel return (
{ // Check if user selected an option const matched = options.find((opt) => opt.value === newValue || opt.label === newValue) if (matched) { setIsEditing(false) - setLocalInputValue(displayLabel) + setSearchQuery('') onInputChange(matched.value) return } // User is typing to search setIsEditing(true) - setLocalInputValue(newValue) + setSearchQuery(newValue) }} onOpenChange={(open) => { if (!open) { setIsEditing(false) - setLocalInputValue(displayLabel) + setSearchQuery('') } onOpenChange(open) }} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx index 361a85582d3..55a03aacf56 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/messages-input/messages-input.tsx @@ -573,6 +573,7 @@ export function MessagesInput({ setOpenPopoverIndex(open ? index : null)} + colorScheme='inverted' >
@@ -663,7 +661,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro disabled className='mb-1' /> -
+
Enter a number between 1 and {config.maxIterations}
@@ -1091,7 +1089,7 @@ function PreviewEditorContent({ const subflowName = block.name || (isLoop ? 'Loop' : 'Parallel') return ( -
+
{/* Header - styled like subflow header */}
+
@@ -1180,7 +1178,7 @@ function PreviewEditorContent({ : 'gray' return ( -
+
{/* Header - styled like editor */}
{block.type !== 'note' && ( @@ -1188,10 +1186,7 @@ function PreviewEditorContent({ className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-sm' style={{ backgroundColor: blockConfig.bgColor }} > - +
)} @@ -1394,7 +1389,7 @@ function PreviewEditorContent({ className='h-[18px] w-[18px] animate-spin rounded-full' style={{ background: - 'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)', + 'conic-gradient(from 0deg, var(--text-tertiary) 0deg 120deg, transparent 120deg 180deg, var(--text-tertiary) 180deg 300deg, transparent 300deg 360deg)', mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))', WebkitMask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index ac19ec9d1d1..4d3e7fd45f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -1,6 +1,7 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import type React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { ArrowLeft } from 'lucide-react' import { Button, Tooltip } from '@/components/emcn' import { redactApiKeys } from '@/lib/core/security/redaction' @@ -126,8 +127,14 @@ interface PreviewProps { initialSelectedBlockId?: string | null /** Whether to auto-select the leftmost block on mount */ autoSelectLeftmost?: boolean + /** Whether to show the close (X) button on the block detail panel */ + showBlockCloseButton?: boolean } +const MIN_PANEL_WIDTH = 280 +const MAX_PANEL_WIDTH = 600 +const DEFAULT_PANEL_WIDTH = 320 + /** * Main preview component that combines PreviewCanvas with PreviewEditor * and handles nested workflow navigation via a stack. @@ -151,7 +158,47 @@ export function Preview({ showBorder = false, initialSelectedBlockId, autoSelectLeftmost = true, + showBlockCloseButton = true, }: PreviewProps) { + const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH) + const panelWidthRef = useRef(DEFAULT_PANEL_WIDTH) + panelWidthRef.current = panelWidth + const isResizingRef = useRef(false) + const startXRef = useRef(0) + const startWidthRef = useRef(0) + + function handleResizeMouseDown(e: React.MouseEvent) { + isResizingRef.current = true + startXRef.current = e.clientX + startWidthRef.current = panelWidthRef.current + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' + } + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current) return + const delta = startXRef.current - e.clientX + setPanelWidth( + Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, startWidthRef.current + delta)) + ) + } + const handleMouseUp = () => { + if (!isResizingRef.current) return + isResizingRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + }, []) + const [pinnedBlockId, setPinnedBlockId] = useState(() => { if (initialSelectedBlockId) return initialSelectedBlockId if (autoSelectLeftmost) { @@ -173,67 +220,55 @@ export function Preview({ return buildBlockExecutions(rootTraceSpans) }, [providedBlockExecutions, rootTraceSpans]) - const blockExecutions = useMemo(() => { - if (workflowStack.length > 0) { - return workflowStack[workflowStack.length - 1].blockExecutions - } - return rootBlockExecutions - }, [workflowStack, rootBlockExecutions]) - - const workflowState = useMemo(() => { - if (workflowStack.length > 0) { - return workflowStack[workflowStack.length - 1].workflowState - } - return rootWorkflowState - }, [workflowStack, rootWorkflowState]) - - const isExecutionMode = useMemo(() => { - return Object.keys(blockExecutions).length > 0 - }, [blockExecutions]) - - const handleDrillDown = useCallback( - (blockId: string, childWorkflowState: WorkflowState) => { - const blockExecution = blockExecutions[blockId] - const childTraceSpans = extractChildTraceSpans(blockExecution) - const childBlockExecutions = buildBlockExecutions(childTraceSpans) - - const workflowName = - childWorkflowState.metadata?.name || - (blockExecution?.output as { childWorkflowName?: string } | undefined)?.childWorkflowName || - 'Nested Workflow' - - setWorkflowStack((prev) => [ - ...prev, - { - workflowState: childWorkflowState, - traceSpans: childTraceSpans, - blockExecutions: childBlockExecutions, - workflowName, - }, - ]) - - const leftmostId = getLeftmostBlockId(childWorkflowState) - setPinnedBlockId(leftmostId) - }, - [blockExecutions] - ) + const currentStackEntry = + workflowStack.length > 0 ? workflowStack[workflowStack.length - 1] : null + const blockExecutions = currentStackEntry + ? currentStackEntry.blockExecutions + : rootBlockExecutions + const workflowState = currentStackEntry ? currentStackEntry.workflowState : rootWorkflowState + + const isExecutionMode = Object.keys(blockExecutions).length > 0 + + function handleDrillDown(blockId: string, childWorkflowState: WorkflowState) { + const blockExecution = blockExecutions[blockId] + const childTraceSpans = extractChildTraceSpans(blockExecution) + const childBlockExecutions = buildBlockExecutions(childTraceSpans) + + const workflowName = + childWorkflowState.metadata?.name || + (blockExecution?.output as { childWorkflowName?: string } | undefined)?.childWorkflowName || + 'Nested Workflow' + + setWorkflowStack((prev) => [ + ...prev, + { + workflowState: childWorkflowState, + traceSpans: childTraceSpans, + blockExecutions: childBlockExecutions, + workflowName, + }, + ]) + + const leftmostId = getLeftmostBlockId(childWorkflowState) + setPinnedBlockId(leftmostId) + } - const handleGoBack = useCallback(() => { + function handleGoBack() { setWorkflowStack((prev) => prev.slice(0, -1)) setPinnedBlockId(null) - }, []) + } - const handleNodeClick = useCallback((blockId: string) => { + function handleNodeClick(blockId: string) { setPinnedBlockId(blockId) - }, []) + } - const handlePaneClick = useCallback(() => { + function handlePaneClick() { setPinnedBlockId(null) - }, []) + } - const handleEditorClose = useCallback(() => { + function handleEditorClose() { setPinnedBlockId(null) - }, []) + } const isNested = workflowStack.length > 0 @@ -289,19 +324,26 @@ export function Preview({
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && ( - +
+ {/* Left-edge resize handle */} +
+ +
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 57465d4edc8..e14ff170d5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -168,27 +168,24 @@ const SidebarTaskItem = memo(function SidebarTaskItem({ }) { const dragGhostRef = useRef(null) - const handleDragStart = useCallback( - (e: React.DragEvent) => { - e.dataTransfer.effectAllowed = 'copyMove' - e.dataTransfer.setData( - SIM_RESOURCES_DRAG_TYPE, - JSON.stringify([{ type: 'task', id: task.id, title: task.name }]) - ) - const ghost = createSidebarDragGhost(task.name, { kind: 'task' }) - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost - }, - [task.id, task.name] - ) + function handleDragStart(e: React.DragEvent) { + e.dataTransfer.effectAllowed = 'copyMove' + e.dataTransfer.setData( + SIM_RESOURCES_DRAG_TYPE, + JSON.stringify([{ type: 'task', id: task.id, title: task.name }]) + ) + const ghost = createSidebarDragGhost(task.name, { kind: 'task' }) + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + } - const handleDragEnd = useCallback(() => { + function handleDragEnd() { if (dragGhostRef.current) { dragGhostRef.current.remove() dragGhostRef.current = null } - }, []) + } return ( diff --git a/apps/sim/blocks/blocks/sap_s4hana.ts b/apps/sim/blocks/blocks/sap_s4hana.ts index fc5cd94c66c..30ff9b900b4 100644 --- a/apps/sim/blocks/blocks/sap_s4hana.ts +++ b/apps/sim/blocks/blocks/sap_s4hana.ts @@ -5,16 +5,16 @@ import type { SapProxyResponse } from '@/tools/sap_s4hana/types' export const SapS4HanaBlock: BlockConfig = { type: 'sap_s4hana', - name: 'SAP S/4HANA', - description: 'Read and write SAP S/4HANA Cloud business data via OData', + name: 'SAP S4HANA', + description: 'Read and write SAP S4HANA Cloud business data via OData', authMode: AuthMode.ApiKey, longDescription: - 'Connect SAP S/4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.', + 'Connect SAP S4HANA Cloud Public Edition with per-tenant OAuth 2.0 client credentials configured in your Communication Arrangements. Read and create business partners, customers, suppliers, sales orders, deliveries (inbound/outbound), billing documents, products, stock and material documents, purchase requisitions, purchase orders, and supplier invoices, or run arbitrary OData v2 queries against any whitelisted Communication Scenario.', docsLink: 'https://docs.sim.ai/tools/sap_s4hana', category: 'tools', integrationType: IntegrationType.Other, tags: ['automation'], - bgColor: '#0A6ED1', + bgColor: '#FFFFFF', icon: SapS4HanaIcon, subBlocks: [ { @@ -700,9 +700,9 @@ export const SapS4HanaBlock: BlockConfig = { title: 'Deployment', type: 'dropdown', options: [ - { label: 'S/4HANA Cloud Public Edition', id: 'cloud_public' }, - { label: 'S/4HANA Cloud Private Edition (RISE)', id: 'cloud_private' }, - { label: 'S/4HANA On-Premise', id: 'on_premise' }, + { label: 'S4HANA Cloud Public Edition', id: 'cloud_public' }, + { label: 'S4HANA Cloud Private Edition (RISE)', id: 'cloud_private' }, + { label: 'S4HANA On-Premise', id: 'on_premise' }, ], value: () => 'cloud_public', required: true, diff --git a/apps/sim/components/emcn/components/input/input.tsx b/apps/sim/components/emcn/components/input/input.tsx index 662dc320dac..b216d67bb52 100644 --- a/apps/sim/components/emcn/components/input/input.tsx +++ b/apps/sim/components/emcn/components/input/input.tsx @@ -26,7 +26,7 @@ import { cn } from '@/lib/core/utils/cn' * Currently supports a 'default' variant. */ const inputVariants = cva( - 'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50', + 'flex w-full touch-manipulation rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 py-1.5 font-medium font-sans text-sm text-[var(--text-primary)] transition-colors placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50 scroll-pr-1', { variants: { variant: { diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts index cd7bdf6b369..0bc9fcfc439 100644 --- a/apps/sim/ee/access-control/hooks/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -124,7 +124,7 @@ export function useCreatePermissionGroup() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId), }) @@ -157,7 +157,7 @@ export function useUpdatePermissionGroup() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId), }) @@ -191,7 +191,7 @@ export function useDeletePermissionGroup() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(variables.workspaceId), }) @@ -221,7 +221,7 @@ export function useRemovePermissionGroupMember() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.members(variables.workspaceId, variables.permissionGroupId), }) @@ -259,7 +259,7 @@ export function useBulkAddPermissionGroupMembers() { } return response.json() as Promise<{ added: number; moved: number }> }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: permissionGroupKeys.members(variables.workspaceId, variables.permissionGroupId), }) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 05019d953e9..340b2aab01a 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -85,14 +85,19 @@ export class BlockExecutor { const blockType = block.metadata?.id ?? '' const isSentinel = isSentinelBlockType(blockType) + // Capture startedAt and startTime at the same synchronous instant so + // blockLog.startedAt and performance.now()-derived durationMs share a + // single reference point. Any executor work below counts toward this block. + const startedAt = new Date().toISOString() + const startTime = performance.now() + let blockLog: BlockLog | undefined if (!isSentinel) { - blockLog = this.createBlockLog(ctx, node.id, block, node) + blockLog = this.createBlockLog(ctx, node.id, block, node, startedAt) ctx.blockLogs.push(blockLog) - await this.callOnBlockStart(ctx, node, block, blockLog.executionOrder) + this.fireBlockStartCallback(ctx, node, block, blockLog.executionOrder) } - const startTime = performance.now() let resolvedInputs: Record = {} const nodeMetadata = { @@ -169,10 +174,11 @@ export class BlockExecutor { })) as NormalizedBlockOutput } + const endedAt = new Date().toISOString() const duration = performance.now() - startTime if (blockLog) { - blockLog.endedAt = new Date().toISOString() + blockLog.endedAt = endedAt blockLog.durationMs = duration blockLog.success = true blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block }) @@ -191,7 +197,7 @@ export class BlockExecutor { const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block, }) - await this.callOnBlockComplete( + this.fireBlockCompleteCallback( ctx, node, block, @@ -249,6 +255,7 @@ export class BlockExecutor { isSentinel: boolean, phase: 'input_resolution' | 'execution' ): Promise { + const endedAt = new Date().toISOString() const duration = performance.now() - startTime const errorMessage = normalizeError(error) const hasResolvedInputs = @@ -273,7 +280,7 @@ export class BlockExecutor { this.state.setBlockOutput(node.id, errorOutput, duration) if (blockLog) { - blockLog.endedAt = new Date().toISOString() + blockLog.endedAt = endedAt blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage @@ -299,7 +306,7 @@ export class BlockExecutor { ? error.childWorkflowInstanceId : undefined const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) - await this.callOnBlockComplete( + this.fireBlockCompleteCallback( ctx, node, block, @@ -351,7 +358,8 @@ export class BlockExecutor { ctx: ExecutionContext, blockId: string, block: SerializedBlock, - node: DAGNode + node: DAGNode, + startedAt: string ): BlockLog { let blockName = block.metadata?.name ?? blockId let loopId: string | undefined @@ -384,7 +392,7 @@ export class BlockExecutor { blockId, blockName, blockType: block.metadata?.id ?? DEFAULTS.BLOCK_TYPE, - startedAt: new Date().toISOString(), + startedAt, executionOrder: getNextExecutionOrder(ctx), endedAt: '', durationMs: 0, @@ -451,39 +459,47 @@ export class BlockExecutor { return redactApiKeys(result) } - private async callOnBlockStart( + /** + * Fires the `onBlockStart` progress callback without blocking block execution. + * Any error is logged and swallowed so callback I/O never stalls the critical path. + */ + private fireBlockStartCallback( ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, executionOrder: number - ): Promise { + ): void { + if (!this.contextExtensions.onBlockStart) return + const blockId = node.metadata?.originalBlockId ?? node.id const blockName = block.metadata?.name ?? blockId const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE - const iterationContext = getIterationContext(ctx, node?.metadata) - if (this.contextExtensions.onBlockStart) { - try { - await this.contextExtensions.onBlockStart( - blockId, - blockName, - blockType, - executionOrder, - iterationContext, - ctx.childWorkflowContext - ) - } catch (error) { + void this.contextExtensions + .onBlockStart( + blockId, + blockName, + blockType, + executionOrder, + iterationContext, + ctx.childWorkflowContext + ) + .catch((error) => { this.execLogger.warn('Block start callback failed', { blockId, blockType, error: toError(error).message, }) - } - } + }) } - private async callOnBlockComplete( + /** + * Fires the `onBlockComplete` progress callback without blocking subsequent blocks. + * The callback typically performs DB writes for progress markers — awaiting it would + * add latency between blocks and skew wall-clock timing in the trace view. + */ + private fireBlockCompleteCallback( ctx: ExecutionContext, node: DAGNode, block: SerializedBlock, @@ -494,39 +510,38 @@ export class BlockExecutor { executionOrder: number, endedAt: string, childWorkflowInstanceId?: string - ): Promise { + ): void { + if (!this.contextExtensions.onBlockComplete) return + const blockId = node.metadata?.originalBlockId ?? node.id const blockName = block.metadata?.name ?? blockId const blockType = block.metadata?.id ?? DEFAULTS.BLOCK_TYPE - const iterationContext = getIterationContext(ctx, node?.metadata) - if (this.contextExtensions.onBlockComplete) { - try { - await this.contextExtensions.onBlockComplete( - blockId, - blockName, - blockType, - { - input, - output, - executionTime: duration, - startedAt, - executionOrder, - endedAt, - childWorkflowInstanceId, - }, - iterationContext, - ctx.childWorkflowContext - ) - } catch (error) { + void this.contextExtensions + .onBlockComplete( + blockId, + blockName, + blockType, + { + input, + output, + executionTime: duration, + startedAt, + executionOrder, + endedAt, + childWorkflowInstanceId, + }, + iterationContext, + ctx.childWorkflowContext + ) + .catch((error) => { this.execLogger.warn('Block completion callback failed', { blockId, blockType, error: toError(error).message, }) - } - } + }) } private preparePauseResumeSelfReference( diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index b8b0d4cfa0d..7d844257d1e 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -74,19 +74,118 @@ export interface SerializedSnapshot { triggerIds: string[] } +/** + * Identifies a tool call emitted by a model iteration. Matches the + * `tool_call.id` convention used by OpenAI, Anthropic, and the OTel GenAI + * spec so tool segments can be correlated back to the iteration that issued + * them. + */ +export interface IterationToolCall { + id: string + name: string + arguments: Record | string +} + +/** + * A single phase of provider execution (model call or tool invocation). + * + * Providers emit these per iteration. Model segments carry the assistant's + * output for that iteration (text, thinking, tool_calls, tokens, finish + * reason) so the trace reveals *why* each tool was invoked — not just that + * it was. All content fields are optional; providers fill in what they have. + */ +export interface ProviderTimingSegment { + type: 'model' | 'tool' + name?: string + startTime: number + endTime: number + duration: number + assistantContent?: string + thinkingContent?: string + toolCalls?: IterationToolCall[] + toolCallId?: string + finishReason?: string + tokens?: BlockTokens + /** Cost for this segment in USD, derived from tokens + model pricing. */ + cost?: { input?: number; output?: number; total?: number } + /** Time-to-first-token in ms (streaming only; first segment typically). */ + ttft?: number + /** Provider system identifier (anthropic, openai, gemini, etc.) — `gen_ai.system`. */ + provider?: string + /** Structured error class (e.g. `rate_limit`, `context_length`). */ + errorType?: string + /** Human-readable error message when this segment failed. */ + errorMessage?: string +} + +/** Timing info reported by an LLM provider for a single block execution. */ +export interface BlockProviderTiming { + startTime: string + endTime: string + duration: number + modelTime?: number + toolsTime?: number + firstResponseTime?: number + iterations?: number + timeSegments?: ProviderTimingSegment[] +} + +/** Cost breakdown from provider usage. */ +export interface BlockCost { + input: number + output: number + total: number + toolCost?: number + pricing?: { + input: number + output: number + cachedInput?: number + updatedAt: string + } +} + +/** Token usage from provider. `prompt`/`completion` are legacy aliases. */ +export interface BlockTokens { + input?: number + output?: number + total?: number + prompt?: number + completion?: number + /** Input tokens served from the provider's prompt cache. */ + cacheRead?: number + /** Input tokens newly written to the provider's prompt cache. */ + cacheWrite?: number + /** Output tokens consumed by reasoning/thinking (o-series, Claude, Gemini). */ + reasoning?: number +} + +/** A single tool invocation recorded by an agent-type block. */ +export interface BlockToolCall { + name: string + duration?: number + startTime?: string + endTime?: string + error?: string + arguments?: Record + input?: Record + result?: Record + output?: Record +} + +/** Normalized tool-call container emitted by providers. */ +export interface BlockToolCalls { + list: BlockToolCall[] + count: number +} + export interface NormalizedBlockOutput { [key: string]: any content?: string model?: string - tokens?: { - input?: number - output?: number - total?: number - } - toolCalls?: { - list: any[] - count: number - } + tokens?: BlockTokens + toolCalls?: BlockToolCalls + providerTiming?: BlockProviderTiming + cost?: BlockCost files?: UserFile[] selectedPath?: { blockId: string @@ -115,8 +214,8 @@ export interface BlockLog { endedAt: string durationMs: number success: boolean - output?: any - input?: any + output?: NormalizedBlockOutput + input?: Record error?: string /** Whether this error was handled by an error handler path (error port) */ errorHandled?: boolean diff --git a/apps/sim/hooks/queries/a2a/tasks.ts b/apps/sim/hooks/queries/a2a/tasks.ts index abb9bbb3acb..b32aace330a 100644 --- a/apps/sim/hooks/queries/a2a/tasks.ts +++ b/apps/sim/hooks/queries/a2a/tasks.ts @@ -132,10 +132,12 @@ export function useSendA2ATask() { return useMutation({ mutationFn: sendA2ATask, - onSuccess: (data, variables) => { - queryClient.invalidateQueries({ - queryKey: a2aTaskKeys.detail(variables.agentUrl, data.taskId), - }) + onSettled: (data, _error, variables) => { + if (data) { + queryClient.invalidateQueries({ + queryKey: a2aTaskKeys.detail(variables.agentUrl, data.taskId), + }) + } }, }) } @@ -255,7 +257,7 @@ export function useCancelA2ATask() { return useMutation({ mutationFn: cancelA2ATask, - onSuccess: (data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: a2aTaskKeys.detail(variables.agentUrl, variables.taskId), }) diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 1afe149113f..8b4e65e1de0 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -330,7 +330,7 @@ export function useCreateChat() { logger.info('Chat deployed successfully:', result.chatUrl) return { chatUrl: result.chatUrl, chatId: result.chatId } }, - onSuccess: (_, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(variables.workflowId), }) @@ -385,7 +385,7 @@ export function useUpdateChat() { logger.info('Chat updated successfully:', result.chatUrl) return { chatUrl: result.chatUrl, chatId } }, - onSuccess: (_, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(variables.workflowId), }) @@ -419,7 +419,7 @@ export function useDeleteChat() { logger.info('Chat deleted successfully') }, - onSuccess: (_, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(variables.workflowId), }) diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 6e7498f13b6..d80ce3a2106 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -459,19 +459,20 @@ export function useUpdateDeploymentVersion() { return response.json() }, - onSuccess: (_, variables) => { - logger.info('Deployment version updated', { - workflowId: variables.workflowId, - version: variables.version, - }) + onSettled: (_data, error, variables) => { + if (!error) { + logger.info('Deployment version updated', { + workflowId: variables.workflowId, + version: variables.version, + }) + } else { + logger.error('Failed to update deployment version', { error }) + } queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(variables.workflowId), }) }, - onError: (error) => { - logger.error('Failed to update deployment version', { error }) - }, }) } @@ -696,18 +697,19 @@ export function useUpdatePublicApi() { return response.json() }, - onSuccess: (_, variables) => { - logger.info('Public API setting updated', { - workflowId: variables.workflowId, - isPublicApi: variables.isPublicApi, - }) + onSettled: (_data, error, variables) => { + if (!error) { + logger.info('Public API setting updated', { + workflowId: variables.workflowId, + isPublicApi: variables.isPublicApi, + }) + } else { + logger.error('Failed to update public API setting', { error }) + } queryClient.invalidateQueries({ queryKey: deploymentKeys.info(variables.workflowId), }) }, - onError: (error) => { - logger.error('Failed to update public API setting', { error }) - }, }) } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 7561ae9713a..ed530775a3c 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -196,7 +196,7 @@ export function useResendWorkspaceInvitation() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: invitationKeys.list(variables.workspaceId), }) @@ -232,7 +232,7 @@ export function useRemoveWorkspaceMember() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId), }) @@ -278,7 +278,7 @@ export function useLeaveWorkspace() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId), }) @@ -313,7 +313,7 @@ export function useUpdateWorkspacePermissions() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId), }) diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index c8a528cccc7..ca85a3f6ff7 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -166,7 +166,7 @@ export function useCreateConnector() { return useMutation({ mutationFn: createConnector, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -213,7 +213,7 @@ export function useUpdateConnector() { return useMutation({ mutationFn: updateConnector, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId), }) @@ -253,7 +253,7 @@ export function useDeleteConnector() { return useMutation({ mutationFn: deleteConnector, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -282,7 +282,7 @@ export function useTriggerSync() { return useMutation({ mutationFn: triggerSync, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -393,7 +393,7 @@ export function useExcludeConnectorDocument() { return useMutation({ mutationFn: excludeConnectorDocuments, - onSuccess: (_, { knowledgeBaseId, connectorId }) => { + onSettled: (_data, _error, { knowledgeBaseId, connectorId }) => { queryClient.invalidateQueries({ queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId), }) @@ -432,7 +432,7 @@ export function useRestoreConnectorDocument() { return useMutation({ mutationFn: restoreConnectorDocuments, - onSuccess: (_, { knowledgeBaseId, connectorId }) => { + onSettled: (_data, _error, { knowledgeBaseId, connectorId }) => { queryClient.invalidateQueries({ queryKey: connectorDocumentKeys.list(knowledgeBaseId, connectorId), }) diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index e1d3343a57d..a93e8b2204a 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -442,7 +442,7 @@ export function useUpdateChunk() { return useMutation({ mutationFn: updateChunk, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -485,7 +485,7 @@ export function useDeleteChunk() { return useMutation({ mutationFn: deleteChunk, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -533,7 +533,7 @@ export function useCreateChunk() { return useMutation({ mutationFn: createChunk, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -584,7 +584,7 @@ export function useUpdateDocument() { return useMutation({ mutationFn: updateDocument, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -624,7 +624,7 @@ export function useDeleteDocument() { return useMutation({ mutationFn: deleteDocument, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -685,7 +685,7 @@ export function useBulkDocumentOperation() { return useMutation({ mutationFn: bulkDocumentOperation, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -733,7 +733,7 @@ export function useCreateKnowledgeBase(workspaceId?: string) { return useMutation({ mutationFn: createKnowledgeBase, - onSuccess: () => { + onSettled: () => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists(), }) @@ -781,7 +781,7 @@ export function useUpdateKnowledgeBase(workspaceId?: string) { onError: (error) => { toast.error(error.message, { duration: 5000 }) }, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists(), }) @@ -819,7 +819,7 @@ export function useDeleteKnowledgeBase(workspaceId?: string) { return useMutation({ mutationFn: deleteKnowledgeBase, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists(), }) @@ -875,7 +875,7 @@ export function useBulkChunkOperation() { return useMutation({ mutationFn: bulkChunkOperation, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -921,7 +921,7 @@ export function useUpdateDocumentTags() { return useMutation({ mutationFn: updateDocumentTags, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId), }) @@ -1054,7 +1054,7 @@ export function useCreateTagDefinition() { return useMutation({ mutationFn: createTagDefinition, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId), }) @@ -1092,7 +1092,7 @@ export function useDeleteTagDefinition() { return useMutation({ mutationFn: deleteTagDefinition, - onSuccess: (_, { knowledgeBaseId }) => { + onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId), }) @@ -1195,7 +1195,7 @@ export function useSaveDocumentTagDefinitions() { return useMutation({ mutationFn: saveDocumentTagDefinitions, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentTagDefinitions(knowledgeBaseId, documentId), }) @@ -1254,7 +1254,7 @@ export function useDeleteDocumentTagDefinitions() { return useMutation({ mutationFn: deleteDocumentTagDefinitions, - onSuccess: (_, { knowledgeBaseId, documentId }) => { + onSettled: (_data, _error, { knowledgeBaseId, documentId }) => { queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentTagDefinitions(knowledgeBaseId, documentId), }) diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index bbbcea5ba7c..edfd58f13d5 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -25,9 +25,9 @@ export const logKeys = { [...logKeys.lists(), workspaceId ?? '', filters] as const, details: () => [...logKeys.all, 'detail'] as const, detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const, - statsAll: () => [...logKeys.all, 'stats'] as const, - stats: (workspaceId: string | undefined, filters: object) => - [...logKeys.statsAll(), workspaceId ?? '', filters] as const, + stats: () => [...logKeys.all, 'stats'] as const, + stat: (workspaceId: string | undefined, filters: object) => + [...logKeys.stats(), workspaceId ?? '', filters] as const, executionSnapshots: () => [...logKeys.all, 'executionSnapshot'] as const, executionSnapshot: (executionId: string | undefined) => [...logKeys.executionSnapshots(), executionId ?? ''] as const, @@ -121,7 +121,7 @@ async function fetchLogsPage( } } -async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { +export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise { const response = await fetch(`/api/logs/${logId}`, { signal }) if (!response.ok) { @@ -170,7 +170,6 @@ export function useLogDetail(logId: string | undefined, options?: UseLogDetailOp enabled: Boolean(logId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, staleTime: 30 * 1000, - placeholderData: keepPreviousData, }) } @@ -223,7 +222,7 @@ export function useDashboardStats( options?: UseDashboardStatsOptions ) { return useQuery({ - queryKey: logKeys.stats(workspaceId, filters), + queryKey: logKeys.stat(workspaceId, filters), queryFn: ({ signal }) => fetchDashboardStats(workspaceId as string, filters, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, @@ -328,7 +327,38 @@ export function useCancelExecution() { onSettled: () => { queryClient.invalidateQueries({ queryKey: logKeys.lists() }) queryClient.invalidateQueries({ queryKey: logKeys.details() }) - queryClient.invalidateQueries({ queryKey: logKeys.statsAll() }) + queryClient.invalidateQueries({ queryKey: logKeys.stats() }) + }, + }) +} + +export function useRetryExecution() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ workflowId, input }: { workflowId: string; input?: unknown }) => { + const res = await fetch(`/api/workflows/${workflowId}/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input, triggerType: 'manual', stream: true }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to retry execution') + } + // The ReadableStream is lazy — start() only runs when read. + // Read one chunk to trigger execution, then cancel. Execution continues + // server-side after client disconnect. + const reader = res.body?.getReader() + if (reader) { + await reader.read() + reader.cancel() + } + return { started: true } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: logKeys.lists() }) + queryClient.invalidateQueries({ queryKey: logKeys.details() }) + queryClient.invalidateQueries({ queryKey: logKeys.stats() }) }, }) } diff --git a/apps/sim/hooks/queries/mothership-admin.ts b/apps/sim/hooks/queries/mothership-admin.ts index 592daea4d86..83194b9abb7 100644 --- a/apps/sim/hooks/queries/mothership-admin.ts +++ b/apps/sim/hooks/queries/mothership-admin.ts @@ -7,12 +7,14 @@ const BASE = '/api/admin/mothership' async function mothershipPost( endpoint: string, environment: MothershipEnv, - body?: Record + body?: Record, + signal?: AbortSignal ) { const res = await fetch(`${BASE}?env=${environment}&endpoint=${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), + signal, }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) @@ -24,10 +26,11 @@ async function mothershipPost( async function mothershipGet( endpoint: string, environment: MothershipEnv, - params?: Record + params?: Record, + signal?: AbortSignal ) { const qs = new URLSearchParams({ env: environment, endpoint, ...params }) - const res = await fetch(`${BASE}?${qs.toString()}`, { method: 'GET' }) + const res = await fetch(`${BASE}?${qs.toString()}`, { method: 'GET', signal }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) throw new Error(err.message || err.error || `Request failed (${res.status})`) @@ -58,13 +61,19 @@ export function useMothershipRequests( ) { return useQuery({ queryKey: mothershipKeys.requests(environment, start, end, userId), - queryFn: () => - mothershipPost('requests', environment, { - start, - end, - ...(userId ? { userId } : {}), - }), + queryFn: ({ signal }) => + mothershipPost( + 'requests', + environment, + { + start, + end, + ...(userId ? { userId } : {}), + }, + signal + ), enabled: !!start && !!end, + staleTime: 60 * 1000, placeholderData: keepPreviousData, }) } @@ -72,8 +81,9 @@ export function useMothershipRequests( export function useMothershipUserBreakdown(environment: MothershipEnv, start: string, end: string) { return useQuery({ queryKey: mothershipKeys.userBreakdown(environment, start, end), - queryFn: () => mothershipPost('user-breakdown', environment, { start, end }), + queryFn: ({ signal }) => mothershipPost('user-breakdown', environment, { start, end }, signal), enabled: !!start && !!end, + staleTime: 60 * 1000, placeholderData: keepPreviousData, }) } @@ -81,7 +91,8 @@ export function useMothershipUserBreakdown(environment: MothershipEnv, start: st export function useMothershipLicenses(environment: MothershipEnv) { return useQuery({ queryKey: mothershipKeys.licenses(environment), - queryFn: () => mothershipGet('licenses', environment), + queryFn: ({ signal }) => mothershipGet('licenses', environment, undefined, signal), + staleTime: 60 * 1000, }) } @@ -92,12 +103,18 @@ export function useMothershipLicenseDetails( ) { return useQuery({ queryKey: mothershipKeys.licenseDetails(environment, id, name), - queryFn: () => - mothershipPost('licenses/details', environment, { - ...(id ? { id } : {}), - ...(name ? { name } : {}), - }), + queryFn: ({ signal }) => + mothershipPost( + 'licenses/details', + environment, + { + ...(id ? { id } : {}), + ...(name ? { name } : {}), + }, + signal + ), enabled: !!(id || name), + staleTime: 60 * 1000, }) } @@ -116,8 +133,10 @@ export function useMothershipEnterpriseStats( ) { return useQuery({ queryKey: mothershipKeys.enterpriseStats(environment, customerType, start, end), - queryFn: () => mothershipPost('enterprise-stats', environment, { customerType, start, end }), + queryFn: ({ signal }) => + mothershipPost('enterprise-stats', environment, { customerType, start, end }, signal), enabled: !!customerType && !!start && !!end, + staleTime: 60 * 1000, placeholderData: keepPreviousData, }) } @@ -125,7 +144,8 @@ export function useMothershipEnterpriseStats( export function useMothershipTrace(environment: MothershipEnv, requestId: string) { return useQuery({ queryKey: mothershipKeys.trace(environment, requestId), - queryFn: () => mothershipGet('traces', environment, { requestId }), + queryFn: ({ signal }) => mothershipGet('traces', environment, { requestId }, signal), enabled: !!requestId, + staleTime: 60 * 1000, }) } diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 38c775a704e..6022baac6bf 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -396,7 +396,7 @@ export function useRemoveMember() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) }) @@ -433,7 +433,7 @@ export function useUpdateOrganizationMemberRole() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) }, @@ -468,7 +468,7 @@ export function useTransferOwnership() { details?: Record }> }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) @@ -503,7 +503,7 @@ export function useUpdateInvitation() { } return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) }, @@ -534,7 +534,7 @@ export function useCancelInvitation() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) @@ -569,7 +569,7 @@ export function useResendInvitation() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) }, @@ -602,7 +602,7 @@ export function useUpdateSeats() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) @@ -640,7 +640,7 @@ export function useUpdateOrganization() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) }, @@ -682,7 +682,7 @@ export function useCreateOrganization() { return data }, - onSuccess: () => { + onSettled: () => { queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) }, diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 6fcc3e16f92..0a47479a242 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -200,7 +200,7 @@ export function useDeleteWorkspace() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) }) }, @@ -237,7 +237,7 @@ export function useUpdateWorkspace() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) }) }, @@ -408,7 +408,7 @@ export function useUpdateWorkspaceSettings() { return response.json() }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.settings(variables.workspaceId), }) diff --git a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts index 2e222ed4a84..261a4f69065 100644 --- a/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts +++ b/apps/sim/lib/copilot/tools/server/jobs/get-job-logs.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { GetJobLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import type { TraceSpan } from '@/lib/logs/types' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('GetJobLogsServerTool') @@ -38,29 +39,68 @@ interface JobLogEntry { tokens?: unknown } -function extractToolCalls(traceSpan: any): ToolCallDetail[] { - if (!traceSpan?.toolCalls || !Array.isArray(traceSpan.toolCalls)) return [] +/** + * Walks the trace-span tree and collects tool invocations from both data shapes: + * - New: `type: 'tool'` spans nested under agent blocks in `children`. + * - Legacy: a `toolCalls` array hanging off the agent span directly (pre-unification). + */ +function collectToolCalls(spans: TraceSpan[] | undefined): ToolCallDetail[] { + if (!spans?.length) return [] + const collected: ToolCallDetail[] = [] + + const visit = (span: TraceSpan) => { + if (span.type === 'tool') { + const output = span.output as { result?: unknown } | undefined + collected.push({ + name: span.name || 'unknown', + input: span.input ?? {}, + output: output?.result ?? span.output, + error: span.status === 'error' ? errorMessageFromSpan(span) : undefined, + duration: span.duration || 0, + }) + return + } + + if (span.toolCalls?.length) { + for (const tc of span.toolCalls) { + collected.push({ + name: tc.name || 'unknown', + input: tc.input ?? {}, + output: tc.output ?? undefined, + error: tc.error || undefined, + duration: tc.duration || 0, + }) + } + } + + if (span.children?.length) { + for (const child of span.children) visit(child) + } + } + + for (const span of spans) visit(span) + return collected +} - return traceSpan.toolCalls.map((tc: any) => ({ - name: tc.name || 'unknown', - input: tc.input || tc.arguments || {}, - output: tc.output || tc.result || undefined, - error: tc.error || undefined, - duration: tc.duration || 0, - })) +function errorMessageFromSpan(span: TraceSpan): string | undefined { + const out = span.output as { error?: unknown } | undefined + if (typeof out?.error === 'string') return out.error + return undefined } -function extractOutputAndError(executionData: any): { +function extractOutputAndError( + executionData: { traceSpans?: TraceSpan[] } & Record +): { output: unknown error: string | undefined toolCalls: ToolCallDetail[] cost: unknown tokens: unknown } { - const traceSpans = executionData?.traceSpans || [] + const traceSpans = executionData?.traceSpans ?? [] const mainSpan = traceSpans[0] - const toolCalls = mainSpan ? extractToolCalls(mainSpan) : [] + const toolCalls = collectToolCalls(traceSpans) const output = mainSpan?.output || executionData?.finalOutput || undefined const cost = mainSpan?.cost || executionData?.cost || undefined const tokens = mainSpan?.tokens || undefined diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts index 3ab0cc2d573..0daf2aa07d0 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts @@ -5,7 +5,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, desc, eq } from 'drizzle-orm' import { GetWorkflowLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import type { TraceSpan } from '@/stores/logs/filters/types' +import type { TraceSpan } from '@/lib/logs/types' const logger = createLogger('GetWorkflowLogsServerTool') diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index 9dd1f409580..34af72809f1 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -100,6 +100,9 @@ const BLOCK_TYPE_MAPPING: Record< } if (span.tokens) { + // `TraceSpan.tokens` is typed as an object, but older persisted logs + // stored it as a bare number (total). Keep the numeric branch for those + // legacy rows. if (typeof span.tokens === 'number') { attrs[GenAIAttributes.USAGE_TOTAL_TOKENS] = span.tokens } else { diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index d9dbdce8c9c..0ace9884075 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -61,43 +61,6 @@ export async function getUserWorkspaceIds(userId: string): Promise { return Array.from(workspaceIds) } -async function upsertCredentialAdminMember(credentialId: string, adminUserId: string) { - const now = new Date() - const [existingMembership] = await db - .select({ id: credentialMember.id, joinedAt: credentialMember.joinedAt }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, adminUserId)) - ) - .limit(1) - - if (existingMembership) { - await db - .update(credentialMember) - .set({ - role: 'admin', - status: 'active', - joinedAt: existingMembership.joinedAt ?? now, - invitedBy: adminUserId, - updatedAt: now, - }) - .where(eq(credentialMember.id, existingMembership.id)) - return - } - - await db.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId: adminUserId, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: adminUserId, - createdAt: now, - updatedAt: now, - }) -} - async function ensureWorkspaceCredentialMemberships( credentialId: string, memberUserIds: string[], @@ -342,78 +305,88 @@ export async function syncPersonalEnvCredentialsForUser(params: { const normalizedKeys = Array.from(new Set(envKeys.filter(Boolean))) const now = new Date() - for (const workspaceId of workspaceIds) { - const existingCredentials = await db - .select({ - id: credential.id, - envKey: credential.envKey, - }) - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId) - ) - ) - - const existingByKey = new Map( - existingCredentials - .filter((row): row is { id: string; envKey: string } => Boolean(row.envKey)) - .map((row) => [row.envKey, row.id]) - ) - - for (const envKey of normalizedKeys) { - const existingId = existingByKey.get(envKey) - if (existingId) { - await upsertCredentialAdminMember(existingId, userId) - continue + await Promise.all( + workspaceIds.map(async (workspaceId) => { + if (normalizedKeys.length > 0) { + await db + .insert(credential) + .values( + normalizedKeys.map((envKey) => ({ + id: generateId(), + workspaceId, + type: 'env_personal' as const, + displayName: envKey, + envKey, + envOwnerUserId: userId, + createdBy: userId, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoNothing() } - const createdId = generateId() - try { - await db.insert(credential).values({ - id: createdId, - workspaceId, - type: 'env_personal', - displayName: envKey, - envKey, - envOwnerUserId: userId, - createdBy: userId, - createdAt: now, - updatedAt: now, - }) - await upsertCredentialAdminMember(createdId, userId) - } catch (error: unknown) { - const code = getPostgresErrorCode(error) - if (code !== '23505') throw error + const currentCredentials = + normalizedKeys.length > 0 + ? await db + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + inArray(credential.envKey, normalizedKeys) + ) + ) + : [] + + if (currentCredentials.length > 0) { + await db + .insert(credentialMember) + .values( + currentCredentials.map(({ id: credentialId }) => ({ + id: generateId(), + credentialId, + userId, + role: 'admin' as const, + status: 'active' as const, + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: [credentialMember.credentialId, credentialMember.userId], + set: { role: 'admin', status: 'active', updatedAt: now }, + }) } - } - if (normalizedKeys.length > 0) { - await db - .delete(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId), - notInArray(credential.envKey, normalizedKeys) + if (normalizedKeys.length > 0) { + await db + .delete(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId), + notInArray(credential.envKey, normalizedKeys) + ) ) - ) - continue - } - - await db - .delete(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envOwnerUserId, userId) - ) - ) - } + } else { + await db + .delete(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_personal'), + eq(credential.envOwnerUserId, userId) + ) + ) + } + }) + ) } export async function getAccessibleEnvCredentials( diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index d538ab738ad..07b7af219bb 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -47,17 +47,6 @@ const TRIGGER_COUNTER_MAP: Record = { a2a: { key: 'totalA2aExecutions', column: 'total_a2a_executions' }, } as const -export interface ToolCall { - name: string - duration: number // in milliseconds - startTime: string // ISO timestamp - endTime: string // ISO timestamp - status: 'success' | 'error' - input?: Record - output?: Record - error?: string -} - const logger = createLogger('ExecutionLogger') function countTraceSpans(traceSpans?: TraceSpan[]): number { @@ -84,6 +73,7 @@ export class ExecutionLogger implements IExecutionLoggerService { models: NonNullable } executionState?: SerializableExecutionState + workflowInput?: unknown }): WorkflowExecutionLog['executionData'] { const { existingExecutionData, @@ -93,6 +83,7 @@ export class ExecutionLogger implements IExecutionLoggerService { completionFailure, executionCost, executionState, + workflowInput, } = params const traceSpanCount = countTraceSpans(traceSpans) @@ -128,6 +119,7 @@ export class ExecutionLogger implements IExecutionLoggerService { }, models: executionCost.models, ...(executionState ? { executionState } : {}), + ...(workflowInput !== undefined ? { workflowInput } : {}), } } @@ -376,6 +368,7 @@ export class ExecutionLogger implements IExecutionLoggerService { completionFailure, executionCost, executionState, + workflowInput, }) const [updatedLog] = await db diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index d8390468324..df08172abe0 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -285,6 +285,46 @@ export class LoggingSession { blockType: string, output: any ): Promise { + // Accumulate cost synchronously before any await so that fire-and-forget + // callers still capture the full cost even if DB writes are not awaited. + const blockOutput = output?.output + if ( + blockOutput?.cost && + typeof blockOutput.cost.total === 'number' && + blockOutput.cost.total > 0 + ) { + const { cost, tokens, model } = blockOutput + + this.accumulatedCost.total += cost.total || 0 + this.accumulatedCost.input += cost.input || 0 + this.accumulatedCost.output += cost.output || 0 + + if (tokens) { + this.accumulatedCost.tokens.input += tokens.input || 0 + this.accumulatedCost.tokens.output += tokens.output || 0 + this.accumulatedCost.tokens.total += tokens.total || 0 + } + + if (model) { + if (!this.accumulatedCost.models[model]) { + this.accumulatedCost.models[model] = { + input: 0, + output: 0, + total: 0, + tokens: { input: 0, output: 0, total: 0 }, + } + } + this.accumulatedCost.models[model].input += cost.input || 0 + this.accumulatedCost.models[model].output += cost.output || 0 + this.accumulatedCost.models[model].total += cost.total || 0 + if (tokens) { + this.accumulatedCost.models[model].tokens.input += tokens.input || 0 + this.accumulatedCost.models[model].tokens.output += tokens.output || 0 + this.accumulatedCost.models[model].tokens.total += tokens.total || 0 + } + } + } + await this.trackProgressWrite( this.persistLastCompletedBlock({ blockId, @@ -295,47 +335,13 @@ export class LoggingSession { }) ) - const blockOutput = output?.output if ( - !blockOutput?.cost || - typeof blockOutput.cost.total !== 'number' || - blockOutput.cost.total <= 0 + blockOutput?.cost && + typeof blockOutput.cost.total === 'number' && + blockOutput.cost.total > 0 ) { - return + void this.trackProgressWrite(this.flushAccumulatedCost()) } - - const { cost, tokens, model } = blockOutput - - this.accumulatedCost.total += cost.total || 0 - this.accumulatedCost.input += cost.input || 0 - this.accumulatedCost.output += cost.output || 0 - - if (tokens) { - this.accumulatedCost.tokens.input += tokens.input || 0 - this.accumulatedCost.tokens.output += tokens.output || 0 - this.accumulatedCost.tokens.total += tokens.total || 0 - } - - if (model) { - if (!this.accumulatedCost.models[model]) { - this.accumulatedCost.models[model] = { - input: 0, - output: 0, - total: 0, - tokens: { input: 0, output: 0, total: 0 }, - } - } - this.accumulatedCost.models[model].input += cost.input || 0 - this.accumulatedCost.models[model].output += cost.output || 0 - this.accumulatedCost.models[model].total += cost.total || 0 - if (tokens) { - this.accumulatedCost.models[model].tokens.input += tokens.input || 0 - this.accumulatedCost.models[model].tokens.output += tokens.output || 0 - this.accumulatedCost.models[model].tokens.total += tokens.total || 0 - } - } - - void this.trackProgressWrite(this.flushAccumulatedCost()) } private async flushAccumulatedCost(): Promise { diff --git a/apps/sim/lib/logs/execution/trace-spans/iteration-grouping.ts b/apps/sim/lib/logs/execution/trace-spans/iteration-grouping.ts new file mode 100644 index 00000000000..832b5eeeca7 --- /dev/null +++ b/apps/sim/lib/logs/execution/trace-spans/iteration-grouping.ts @@ -0,0 +1,324 @@ +import { createLogger } from '@sim/logger' +import type { TraceSpan } from '@/lib/logs/types' +import { stripCloneSuffixes } from '@/executor/utils/subflow-utils' + +const logger = createLogger('IterationGrouping') + +/** Counter state for generating sequential container names. */ +interface ContainerNameCounters { + loopNumbers: Map + parallelNumbers: Map + loopCounter: number + parallelCounter: number +} + +/** + * Builds a container-level TraceSpan (iteration wrapper or top-level container) + * from its source spans and resolved children. + */ +function buildContainerSpan(opts: { + id: string + name: string + type: string + sourceSpans: TraceSpan[] + children: TraceSpan[] +}): TraceSpan { + const startTimes = opts.sourceSpans.map((s) => new Date(s.startTime).getTime()) + const endTimes = opts.sourceSpans.map((s) => new Date(s.endTime).getTime()) + + // Guard against empty sourceSpans — Math.min/max of empty array returns ±Infinity + // which produces NaN durations and invalid Dates downstream. + const nowMs = Date.now() + const earliestStart = startTimes.length > 0 ? Math.min(...startTimes) : nowMs + const latestEnd = endTimes.length > 0 ? Math.max(...endTimes) : nowMs + + const hasErrors = opts.sourceSpans.some((s) => s.status === 'error') + const allErrorsHandled = + hasErrors && opts.children.every((s) => s.status !== 'error' || s.errorHandled) + + return { + id: opts.id, + name: opts.name, + type: opts.type, + duration: Math.max(0, latestEnd - earliestStart), + startTime: new Date(earliestStart).toISOString(), + endTime: new Date(latestEnd).toISOString(), + status: hasErrors ? 'error' : 'success', + ...(allErrorsHandled && { errorHandled: true }), + children: opts.children, + } +} + +/** + * Resolves a container name from normal (non-iteration) spans or assigns a sequential number. + * Strips clone suffixes so all clones of the same container share one name/number. + */ +function resolveContainerName( + containerId: string, + containerType: 'parallel' | 'loop', + normalSpans: TraceSpan[], + counters: ContainerNameCounters +): string { + const originalId = stripCloneSuffixes(containerId) + + const matchingBlock = normalSpans.find( + (s) => s.blockId === originalId && s.type === containerType + ) + if (matchingBlock?.name) return matchingBlock.name + + if (containerType === 'parallel') { + if (!counters.parallelNumbers.has(originalId)) { + counters.parallelNumbers.set(originalId, counters.parallelCounter++) + } + return `Parallel ${counters.parallelNumbers.get(originalId)}` + } + if (!counters.loopNumbers.has(originalId)) { + counters.loopNumbers.set(originalId, counters.loopCounter++) + } + return `Loop ${counters.loopNumbers.get(originalId)}` +} + +/** + * Classifies a span's immediate container ID and type from its metadata. + * Returns undefined for non-iteration spans. + */ +function classifySpanContainer( + span: TraceSpan +): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { + if (span.parallelId) { + return { containerId: span.parallelId, containerType: 'parallel' } + } + if (span.loopId) { + return { containerId: span.loopId, containerType: 'loop' } + } + if (span.blockId?.includes('_parallel_')) { + const match = span.blockId.match(/_parallel_([^_]+)_iteration_/) + if (match) { + return { containerId: match[1], containerType: 'parallel' } + } + } + return undefined +} + +/** + * Finds the outermost container for a span. For nested spans, this is parentIterations[0]. + * For flat spans, this is the span's own immediate container. + */ +function getOutermostContainer( + span: TraceSpan +): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { + if (span.parentIterations && span.parentIterations.length > 0) { + const outermost = span.parentIterations[0] + return { + containerId: outermost.iterationContainerId, + containerType: outermost.iterationType as 'parallel' | 'loop', + } + } + return classifySpanContainer(span) +} + +/** + * Builds the iteration-level hierarchy for a container, recursively nesting + * any deeper subflows. Works with both: + * - Direct spans (spans whose immediate container matches) + * - Nested spans (spans with parentIterations pointing through this container) + */ +function buildContainerChildren( + containerType: 'parallel' | 'loop', + containerId: string, + spans: TraceSpan[], + normalSpans: TraceSpan[], + counters: ContainerNameCounters +): TraceSpan[] { + const iterationType = containerType === 'parallel' ? 'parallel-iteration' : 'loop-iteration' + + const iterationGroups = new Map() + + for (const span of spans) { + let iterIdx: number | undefined + + if ( + span.parentIterations && + span.parentIterations.length > 0 && + span.parentIterations[0].iterationContainerId === containerId + ) { + iterIdx = span.parentIterations[0].iterationCurrent + } else { + iterIdx = span.iterationIndex + } + + if (iterIdx === undefined) { + logger.warn('Skipping iteration span without iterationIndex', { + spanId: span.id, + blockId: span.blockId, + containerId, + }) + continue + } + + if (!iterationGroups.has(iterIdx)) iterationGroups.set(iterIdx, []) + iterationGroups.get(iterIdx)!.push(span) + } + + const iterationChildren: TraceSpan[] = [] + const sortedIterations = Array.from(iterationGroups.entries()).sort(([a], [b]) => a - b) + + for (const [iterationIndex, iterSpans] of sortedIterations) { + const directLeaves: TraceSpan[] = [] + const deeperSpans: TraceSpan[] = [] + + for (const span of iterSpans) { + if ( + span.parentIterations && + span.parentIterations.length > 0 && + span.parentIterations[0].iterationContainerId === containerId + ) { + deeperSpans.push({ + ...span, + parentIterations: span.parentIterations.slice(1), + }) + } else { + directLeaves.push({ + ...span, + name: span.name.replace(/ \(iteration \d+\)$/, ''), + }) + } + } + + const nestedResult = groupIterationBlocksRecursive( + [...directLeaves, ...deeperSpans], + normalSpans, + counters + ) + + iterationChildren.push( + buildContainerSpan({ + id: `${containerId}-iteration-${iterationIndex}`, + name: `Iteration ${iterationIndex}`, + type: iterationType, + sourceSpans: iterSpans, + children: nestedResult, + }) + ) + } + + return iterationChildren +} + +/** + * Core recursive algorithm for grouping iteration blocks. + * + * Handles two cases: + * 1. **Flat** (backward compat): spans have loopId/parallelId + iterationIndex but no + * parentIterations. Grouped by immediate container -> iteration -> leaf. + * 2. **Nested** (new): spans have parentIterations chains. The outermost ancestor in the + * chain determines the top-level container. Iteration spans are peeled one level at a + * time and recursed. + */ +function groupIterationBlocksRecursive( + spans: TraceSpan[], + normalSpans: TraceSpan[], + counters: ContainerNameCounters +): TraceSpan[] { + const result: TraceSpan[] = [] + const iterationSpans: TraceSpan[] = [] + const nonIterationSpans: TraceSpan[] = [] + + for (const span of spans) { + if ( + (span.name.match(/^(.+) \(iteration (\d+)\)$/) && + (span.loopId || span.parallelId || span.blockId?.includes('_parallel_'))) || + (span.parentIterations && span.parentIterations.length > 0) + ) { + iterationSpans.push(span) + } else { + nonIterationSpans.push(span) + } + } + + const containerIdsWithIterations = new Set() + for (const span of iterationSpans) { + const outermost = getOutermostContainer(span) + if (outermost) containerIdsWithIterations.add(outermost.containerId) + } + + const nonContainerSpans = nonIterationSpans.filter( + (span) => + (span.type !== 'parallel' && span.type !== 'loop') || + span.status === 'error' || + (span.blockId && !containerIdsWithIterations.has(span.blockId)) + ) + + if (iterationSpans.length === 0) { + result.push(...nonContainerSpans) + result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + return result + } + + const containerGroups = new Map< + string, + { type: 'parallel' | 'loop'; containerId: string; containerName: string; spans: TraceSpan[] } + >() + + for (const span of iterationSpans) { + const outermost = getOutermostContainer(span) + if (!outermost) continue + + const { containerId, containerType } = outermost + const groupKey = `${containerType}_${containerId}` + + if (!containerGroups.has(groupKey)) { + const containerName = resolveContainerName(containerId, containerType, normalSpans, counters) + containerGroups.set(groupKey, { + type: containerType, + containerId, + containerName, + spans: [], + }) + } + containerGroups.get(groupKey)!.spans.push(span) + } + + for (const [, group] of containerGroups) { + const { type, containerId, containerName, spans: containerSpans } = group + + const iterationChildren = buildContainerChildren( + type, + containerId, + containerSpans, + normalSpans, + counters + ) + + result.push( + buildContainerSpan({ + id: `${type === 'parallel' ? 'parallel' : 'loop'}-execution-${containerId}`, + name: containerName, + type, + sourceSpans: containerSpans, + children: iterationChildren, + }) + ) + } + + result.push(...nonContainerSpans) + result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + + return result +} + +/** + * Groups iteration-based blocks (parallel and loop) by organizing their iteration spans + * into a hierarchical structure with proper parent-child relationships. + * Supports recursive nesting via parentIterations (e.g., parallel-in-parallel, loop-in-loop). + */ +export function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] { + const normalSpans = spans.filter((s) => !s.name.match(/^(.+) \(iteration (\d+)\)$/)) + const counters: ContainerNameCounters = { + loopNumbers: new Map(), + parallelNumbers: new Map(), + loopCounter: 1, + parallelCounter: 1, + } + return groupIterationBlocksRecursive(spans, normalSpans, counters) +} diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts new file mode 100644 index 00000000000..30329a8b9b5 --- /dev/null +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -0,0 +1,384 @@ +import { createLogger } from '@sim/logger' +import type { ProviderTiming, TraceSpan } from '@/lib/logs/types' +import { + isConditionBlockType, + isWorkflowBlockType, + stripCustomToolPrefix, +} from '@/executor/constants' +import type { + BlockLog, + BlockToolCall, + NormalizedBlockOutput, + ProviderTimingSegment, +} from '@/executor/types' + +const logger = createLogger('SpanFactory') + +/** A BlockLog that has already passed the id/type validity check. */ +type ValidBlockLog = BlockLog & { blockType: string } + +/** + * Creates a TraceSpan from a BlockLog. Returns null for invalid logs. + * + * Children are unified under `span.children` regardless of source: + * - Provider `timeSegments` become model/tool child spans with tool I/O merged in + * - `output.toolCalls` (no segments) become tool child spans + * - Child workflow spans are flattened into children + */ +export function createSpanFromLog(log: BlockLog): TraceSpan | null { + if (!log.blockId || !log.blockType) return null + const validLog = log as ValidBlockLog + + const span = createBaseSpan(validLog) + + if (!isConditionBlockType(validLog.blockType)) { + enrichWithProviderMetadata(span, validLog) + + if (!isWorkflowBlockType(validLog.blockType)) { + const segments = validLog.output?.providerTiming?.timeSegments + span.children = segments + ? buildChildrenFromTimeSegments(span, validLog, segments) + : buildChildrenFromToolCalls(span, validLog) + } + } + + if (isWorkflowBlockType(validLog.blockType)) { + attachChildWorkflowSpans(span, validLog) + } + + return span +} + +/** Creates the base span with id, name, type, timing, status, and metadata. */ +function createBaseSpan(log: ValidBlockLog): TraceSpan { + const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` + const output = extractDisplayOutput(log) + const childIds = extractChildWorkflowIds(log.output) + + return { + id: spanId, + name: log.blockName ?? log.blockId, + type: log.blockType, + duration: log.durationMs, + startTime: log.startedAt, + endTime: log.endedAt, + status: log.error ? 'error' : 'success', + children: [], + blockId: log.blockId, + input: log.input, + output, + ...(childIds ?? {}), + ...(log.errorHandled && { errorHandled: true }), + ...(log.loopId && { loopId: log.loopId }), + ...(log.parallelId && { parallelId: log.parallelId }), + ...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }), + ...(log.parentIterations?.length && { parentIterations: log.parentIterations }), + } +} + +/** + * Strips internal fields from the block output for display and merges + * the block-level error into output so the UI renders it alongside data. + */ +function extractDisplayOutput(log: ValidBlockLog): Record { + const { childWorkflowSnapshotId, childWorkflowId, ...rest } = log.output ?? {} + return log.error ? { ...rest, error: log.error } : rest +} + +/** Pulls child-workflow identifiers off the output so they can live on the span. */ +function extractChildWorkflowIds( + output: NormalizedBlockOutput | undefined +): { childWorkflowSnapshotId?: string; childWorkflowId?: string } | undefined { + if (!output) return undefined + const ids: { childWorkflowSnapshotId?: string; childWorkflowId?: string } = {} + if (typeof output.childWorkflowSnapshotId === 'string') { + ids.childWorkflowSnapshotId = output.childWorkflowSnapshotId + } + if (typeof output.childWorkflowId === 'string') { + ids.childWorkflowId = output.childWorkflowId + } + return ids.childWorkflowSnapshotId || ids.childWorkflowId ? ids : undefined +} + +/** Enriches a span with provider timing, cost, tokens, and model from block output. */ +function enrichWithProviderMetadata(span: TraceSpan, log: ValidBlockLog): void { + const output = log.output + if (!output) return + + if (output.providerTiming) { + const pt = output.providerTiming + const timing: ProviderTiming = { + duration: pt.duration, + startTime: pt.startTime, + endTime: pt.endTime, + segments: pt.timeSegments ?? [], + } + span.providerTiming = timing + } + + if (output.cost) { + const { input, output: out, total } = output.cost + span.cost = { input, output: out, total } + } + + if (output.tokens) { + const t = output.tokens + const input = + typeof t.input === 'number' ? t.input : typeof t.prompt === 'number' ? t.prompt : undefined + const outputTokens = + typeof t.output === 'number' + ? t.output + : typeof t.completion === 'number' + ? t.completion + : undefined + const totalExplicit = typeof t.total === 'number' ? t.total : undefined + const total = + totalExplicit ?? + (input !== undefined || outputTokens !== undefined + ? (input ?? 0) + (outputTokens ?? 0) + : undefined) + span.tokens = { + ...(input !== undefined && { input }), + ...(outputTokens !== undefined && { output: outputTokens }), + ...(total !== undefined && { total }), + } + } + + if (typeof output.model === 'string') { + span.model = output.model + } +} + +/** + * Builds child spans from provider `timeSegments`, matching tool segments to + * their corresponding tool call I/O by name in sequential order. + */ +function buildChildrenFromTimeSegments( + span: TraceSpan, + log: ValidBlockLog, + segments: ProviderTimingSegment[] +): TraceSpan[] { + const toolCallsByName = groupToolCallsByName(resolveToolCallsList(log.output)) + const toolCallIndices = new Map() + + return segments.map((segment, index) => { + const segmentStartTime = new Date(segment.startTime).toISOString() + let segmentEndTime = new Date(segment.endTime).toISOString() + let segmentDuration = segment.duration + + // The final model segment sometimes closes before the block ends (e.g. when + // post-processing runs after the stream). Extend it to the block endTime so + // the Gantt bar fills to the edge rather than leaving a trailing gap. + if (index === segments.length - 1 && segment.type === 'model' && log.endedAt) { + const blockEndMs = new Date(log.endedAt).getTime() + const segmentEndMs = new Date(segment.endTime).getTime() + if (blockEndMs > segmentEndMs) { + segmentEndTime = log.endedAt + segmentDuration = blockEndMs - new Date(segment.startTime).getTime() + } + } + + if (segment.type === 'tool') { + const normalizedName = stripCustomToolPrefix(segment.name ?? '') + const callsForName = toolCallsByName.get(normalizedName) ?? [] + const currentIndex = toolCallIndices.get(normalizedName) ?? 0 + const match = callsForName[currentIndex] + toolCallIndices.set(normalizedName, currentIndex + 1) + + const toolChild: TraceSpan = { + id: `${span.id}-segment-${index}`, + name: normalizedName, + type: 'tool', + duration: segment.duration, + startTime: segmentStartTime, + endTime: segmentEndTime, + status: match?.error || segment.errorMessage ? 'error' : 'success', + input: match?.arguments ?? match?.input, + output: match?.error + ? { error: match.error, ...(match.result ?? match.output ?? {}) } + : (match?.result ?? match?.output), + } + if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId + if (segment.errorType) toolChild.errorType = segment.errorType + if (segment.errorMessage) toolChild.errorMessage = segment.errorMessage + return toolChild + } + + const modelChild: TraceSpan = { + id: `${span.id}-segment-${index}`, + name: segment.name ?? 'Model', + type: 'model', + duration: segmentDuration, + startTime: segmentStartTime, + endTime: segmentEndTime, + status: segment.errorMessage ? 'error' : 'success', + } + + if (segment.assistantContent) { + modelChild.output = { content: segment.assistantContent } + } + if (segment.thinkingContent) { + modelChild.thinking = segment.thinkingContent + } + if (segment.toolCalls && segment.toolCalls.length > 0) { + modelChild.modelToolCalls = segment.toolCalls + } + if (segment.finishReason) { + modelChild.finishReason = segment.finishReason + } + if (segment.tokens) { + modelChild.tokens = segment.tokens + } + if (segment.cost) { + modelChild.cost = segment.cost + } + if (typeof segment.ttft === 'number' && segment.ttft >= 0) { + modelChild.ttft = segment.ttft + } + if (span.model) { + modelChild.model = span.model + } + if (segment.provider) { + modelChild.provider = segment.provider + } + if (segment.errorType) { + modelChild.errorType = segment.errorType + } + if (segment.errorMessage) { + modelChild.errorMessage = segment.errorMessage + } + + return modelChild + }) +} + +/** + * Builds tool-call child spans when the provider did not emit `timeSegments`. + * Each tool call becomes a full TraceSpan of `type: 'tool'`. + */ +function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceSpan[] { + const toolCalls = resolveToolCallsList(log.output) + if (toolCalls.length === 0) return [] + + return toolCalls.map((tc, index) => { + const startTime = tc.startTime ?? log.startedAt + const endTime = tc.endTime ?? log.endedAt + return { + id: `${span.id}-tool-${index}`, + name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'), + type: 'tool', + duration: tc.duration ?? 0, + startTime, + endTime, + status: tc.error ? 'error' : 'success', + input: tc.arguments ?? tc.input, + output: tc.error + ? { error: tc.error, ...(tc.result ?? tc.output ?? {}) } + : (tc.result ?? tc.output), + } + }) +} + +/** Groups tool calls by their stripped name for sequential matching against segments. */ +function groupToolCallsByName(toolCalls: BlockToolCall[]): Map { + const byName = new Map() + for (const tc of toolCalls) { + const name = stripCustomToolPrefix(tc.name ?? '') + const list = byName.get(name) + if (list) list.push(tc) + else byName.set(name, [tc]) + } + return byName +} + +/** + * Resolves the tool calls list from block output. Providers write a normalized + * `{list, count}` container; a legacy streaming path embeds calls under + * `executionData.output.toolCalls`. The `Array.isArray` branches guard against + * persisted logs from before the container shape was normalized, where + * `toolCalls` was stored as a plain array — still observed in older DB rows. + */ +function resolveToolCallsList(output: NormalizedBlockOutput | undefined): BlockToolCall[] { + if (!output) return [] + + const direct = output.toolCalls + if (direct) { + if (Array.isArray(direct)) return direct + if (direct.list) return direct.list + logger.warn('Unexpected toolCalls shape on block output — no list extracted', { + shape: typeof direct, + }) + return [] + } + + const legacy = (output.executionData as { output?: { toolCalls?: unknown } } | undefined)?.output + ?.toolCalls + if (!legacy) return [] + if (Array.isArray(legacy)) return legacy as BlockToolCall[] + if (typeof legacy === 'object' && legacy !== null && 'list' in legacy) { + return ((legacy as { list?: BlockToolCall[] }).list ?? []) as BlockToolCall[] + } + logger.warn('Unexpected legacy executionData.output.toolCalls shape — no list extracted', { + shape: typeof legacy, + }) + return [] +} + +/** Extracts and flattens child workflow trace spans into the parent span's children. */ +function attachChildWorkflowSpans(span: TraceSpan, log: ValidBlockLog): void { + const childTraceSpans = log.childTraceSpans ?? log.output?.childTraceSpans + if (!childTraceSpans?.length) return + + span.children = flattenWorkflowChildren(childTraceSpans) + span.output = stripChildTraceSpansFromOutput(span.output) +} + +/** True when a span is a synthetic workflow wrapper (no blockId). */ +function isSyntheticWorkflowWrapper(span: TraceSpan): boolean { + return span.type === 'workflow' && !span.blockId +} + +/** Reads nested `childTraceSpans` off a span's output, or `[]` if absent. */ +function extractOutputChildren(output: TraceSpan['output']): TraceSpan[] { + const nested = (output as { childTraceSpans?: TraceSpan[] } | undefined)?.childTraceSpans + return Array.isArray(nested) ? nested : [] +} + +/** Returns a copy of `output` with `childTraceSpans` removed, or undefined unchanged. */ +function stripChildTraceSpansFromOutput( + output: TraceSpan['output'] +): TraceSpan['output'] | undefined { + if (!output || !('childTraceSpans' in output)) return output + const { childTraceSpans: _, ...rest } = output as Record + return rest +} + +/** Recursively flattens synthetic workflow wrappers, preserving real block spans. */ +function flattenWorkflowChildren(spans: TraceSpan[]): TraceSpan[] { + const flattened: TraceSpan[] = [] + + for (const span of spans) { + if (isSyntheticWorkflowWrapper(span)) { + if (span.children?.length) { + flattened.push(...flattenWorkflowChildren(span.children)) + } + continue + } + + const directChildren = span.children ?? [] + const outputChildren = extractOutputChildren(span.output) + const allChildren = [...directChildren, ...outputChildren] + + const nextSpan: TraceSpan = { ...span } + if (allChildren.length > 0) { + nextSpan.children = flattenWorkflowChildren(allChildren) + } + if (outputChildren.length > 0) { + nextSpan.output = stripChildTraceSpansFromOutput(nextSpan.output) + } + + flattened.push(nextSpan) + } + + return flattened +} diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index dd226ee857a..f15ff6f2fc2 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -174,11 +174,12 @@ describe('buildTraceSpans', () => { expect(traceSpans).toHaveLength(1) const agentSpan = traceSpans[0] expect(agentSpan.type).toBe('agent') - expect(agentSpan.toolCalls).toBeDefined() - expect(agentSpan.toolCalls).toHaveLength(2) + expect(agentSpan.children).toBeDefined() + expect(agentSpan.children).toHaveLength(2) // Check first tool call - const firstToolCall = agentSpan.toolCalls![0] + const firstToolCall = agentSpan.children![0] + expect(firstToolCall.type).toBe('tool') expect(firstToolCall.name).toBe('test_tool') // custom_ prefix should be stripped expect(firstToolCall.duration).toBe(1000) expect(firstToolCall.status).toBe('success') @@ -186,7 +187,8 @@ describe('buildTraceSpans', () => { expect(firstToolCall.output).toEqual({ output: 'test output' }) // Check second tool call - const secondToolCall = agentSpan.toolCalls![1] + const secondToolCall = agentSpan.children![1] + expect(secondToolCall.type).toBe('tool') expect(secondToolCall.name).toBe('http_request') expect(secondToolCall.duration).toBe(2000) expect(secondToolCall.status).toBe('success') @@ -238,10 +240,11 @@ describe('buildTraceSpans', () => { expect(traceSpans).toHaveLength(1) const agentSpan = traceSpans[0] - expect(agentSpan.toolCalls).toBeDefined() - expect(agentSpan.toolCalls).toHaveLength(1) + expect(agentSpan.children).toBeDefined() + expect(agentSpan.children).toHaveLength(1) - const toolCall = agentSpan.toolCalls![0] + const toolCall = agentSpan.children![0] + expect(toolCall.type).toBe('tool') expect(toolCall.name).toBe('serper_search') expect(toolCall.duration).toBe(1500) expect(toolCall.status).toBe('success') @@ -293,10 +296,11 @@ describe('buildTraceSpans', () => { expect(traceSpans).toHaveLength(1) const agentSpan = traceSpans[0] - expect(agentSpan.toolCalls).toBeDefined() - expect(agentSpan.toolCalls).toHaveLength(1) + expect(agentSpan.children).toBeDefined() + expect(agentSpan.children).toHaveLength(1) - const toolCall = agentSpan.toolCalls![0] + const toolCall = agentSpan.children![0] + expect(toolCall.type).toBe('tool') expect(toolCall.name).toBe('analysis_tool') // custom_ prefix should be stripped expect(toolCall.duration).toBe(2000) expect(toolCall.status).toBe('success') @@ -2082,4 +2086,124 @@ describe('nested subflow grouping via parentIterations', () => { expect(parallel!.children).toHaveLength(2) } ) + + it.concurrent('propagates per-iteration segment content to model child spans', () => { + const result: ExecutionResult = { + success: true, + output: { content: 'final' }, + logs: [ + { + blockId: 'agent-1', + blockName: 'Agent', + blockType: 'agent', + startedAt: '2024-01-01T10:00:00.000Z', + endedAt: '2024-01-01T10:00:04.000Z', + durationMs: 4000, + success: true, + input: { userPrompt: 'hi' }, + output: { + content: 'final', + model: 'claude-3-7-sonnet', + providerTiming: { + duration: 4000, + startTime: '2024-01-01T10:00:00.000Z', + endTime: '2024-01-01T10:00:04.000Z', + timeSegments: [ + { + type: 'model', + name: 'claude-3-7-sonnet', + startTime: 1704103200000, + endTime: 1704103202000, + duration: 2000, + assistantContent: 'reasoning about request', + thinkingContent: 'let me think step by step', + toolCalls: [{ id: 'call_abc', name: 'lookup', arguments: { q: 'test' } }], + finishReason: 'tool_use', + tokens: { input: 100, output: 20, total: 120, cacheRead: 5, reasoning: 8 }, + cost: { input: 0.001, output: 0.002, total: 0.003 }, + ttft: 450, + provider: 'anthropic', + }, + { + type: 'tool', + name: 'lookup', + startTime: 1704103202000, + endTime: 1704103203000, + duration: 1000, + toolCallId: 'call_abc', + errorType: 'TimeoutError', + errorMessage: 'tool timed out', + }, + { + type: 'model', + name: 'claude-3-7-sonnet', + startTime: 1704103203000, + endTime: 1704103204000, + duration: 1000, + assistantContent: 'final answer', + finishReason: 'end_turn', + tokens: { input: 130, output: 10, total: 140 }, + cost: { input: 0.002, output: 0.001, total: 0.003 }, + provider: 'anthropic', + errorType: 'RateLimitError', + errorMessage: 'too many requests', + }, + ], + }, + toolCalls: { + list: [ + { + name: 'lookup', + arguments: { q: 'test' }, + result: { hit: true }, + duration: 1000, + }, + ], + count: 1, + }, + }, + }, + ], + } + + const { traceSpans } = buildTraceSpans(result) + const children = traceSpans[0].children! + expect(children).toHaveLength(3) + + const [firstModel, tool, secondModel] = children + + expect(firstModel.type).toBe('model') + expect(firstModel.output).toEqual({ content: 'reasoning about request' }) + expect(firstModel.thinking).toBe('let me think step by step') + expect(firstModel.modelToolCalls).toEqual([ + { id: 'call_abc', name: 'lookup', arguments: { q: 'test' } }, + ]) + expect(firstModel.finishReason).toBe('tool_use') + expect(firstModel.tokens).toEqual({ + input: 100, + output: 20, + total: 120, + cacheRead: 5, + reasoning: 8, + }) + expect(firstModel.cost).toEqual({ input: 0.001, output: 0.002, total: 0.003 }) + expect(firstModel.ttft).toBe(450) + expect(firstModel.provider).toBe('anthropic') + expect(firstModel.status).toBe('success') + + expect(tool.type).toBe('tool') + expect(tool.toolCallId).toBe('call_abc') + expect(tool.errorType).toBe('TimeoutError') + expect(tool.errorMessage).toBe('tool timed out') + expect(tool.status).toBe('error') + + expect(secondModel.type).toBe('model') + expect(secondModel.output).toEqual({ content: 'final answer' }) + expect(secondModel.thinking).toBeUndefined() + expect(secondModel.modelToolCalls).toBeUndefined() + expect(secondModel.finishReason).toBe('end_turn') + expect(secondModel.errorType).toBe('RateLimitError') + expect(secondModel.errorMessage).toBe('too many requests') + expect(secondModel.status).toBe('error') + }) }) diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index f367058fd6f..815c5159351 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -1,14 +1,7 @@ -import { createLogger } from '@sim/logger' -import type { ToolCall, TraceSpan } from '@/lib/logs/types' -import { - isConditionBlockType, - isWorkflowBlockType, - stripCustomToolPrefix, -} from '@/executor/constants' -import type { ExecutionResult } from '@/executor/types' -import { stripCloneSuffixes } from '@/executor/utils/subflow-utils' - -const logger = createLogger('TraceSpans') +import { groupIterationBlocks } from '@/lib/logs/execution/trace-spans/iteration-grouping' +import { createSpanFromLog } from '@/lib/logs/execution/trace-spans/span-factory' +import type { TraceSpan } from '@/lib/logs/types' +import type { BlockLog, ExecutionResult } from '@/executor/types' /** * Keys that should be recursively filtered from output display. @@ -43,820 +36,95 @@ export function filterHiddenOutputKeys(value: unknown): unknown { return value } -function isSyntheticWorkflowWrapper(span: TraceSpan | undefined): boolean { - if (!span || span.type !== 'workflow') return false - return !span.blockId -} - -function flattenWorkflowChildren(spans: TraceSpan[]): TraceSpan[] { - const flattened: TraceSpan[] = [] - - spans.forEach((span) => { - if (isSyntheticWorkflowWrapper(span)) { - if (span.children && Array.isArray(span.children)) { - flattened.push(...flattenWorkflowChildren(span.children)) - } - return - } - - const processedSpan: TraceSpan = { ...span } - - const directChildren = Array.isArray(span.children) ? span.children : [] - const outputChildren = - span.output && - typeof span.output === 'object' && - Array.isArray((span.output as { childTraceSpans?: TraceSpan[] }).childTraceSpans) - ? ((span.output as { childTraceSpans?: TraceSpan[] }).childTraceSpans as TraceSpan[]) - : [] - - const allChildren = [...directChildren, ...outputChildren] - if (allChildren.length > 0) { - processedSpan.children = flattenWorkflowChildren(allChildren) - } - - if (outputChildren.length > 0 && processedSpan.output) { - const { childTraceSpans: _, ...cleanOutput } = processedSpan.output as { - childTraceSpans?: TraceSpan[] - } & Record - processedSpan.output = cleanOutput - } - - flattened.push(processedSpan) - }) - - return flattened -} - +/** + * Builds a hierarchical trace span tree from execution logs. + * + * Pipeline: + * 1. Each BlockLog becomes a TraceSpan via `createSpanFromLog`. + * 2. Spans are sorted by start time to form a flat list of root spans. + * 3. Loop/parallel iterations are grouped into container spans via `groupIterationBlocks`. + * 4. A synthetic "Workflow Execution" root wraps the grouped spans and provides + * relative timestamps + total duration derived from the earliest start / latest end. + */ export function buildTraceSpans(result: ExecutionResult): { traceSpans: TraceSpan[] totalDuration: number } { - if (!result.logs || result.logs.length === 0) { + if (!result.logs?.length) { return { traceSpans: [], totalDuration: 0 } } - const spanMap = new Map() - - const parentChildMap = new Map() - - type Connection = { source: string; target: string } - const workflowConnections: Connection[] = result.metadata?.workflowConnections || [] - if (workflowConnections.length > 0) { - workflowConnections.forEach((conn: Connection) => { - if (conn.source && conn.target) { - parentChildMap.set(conn.target, conn.source) - } - }) - } - - result.logs.forEach((log) => { - if (!log.blockId || !log.blockType) return - - const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` - const isCondition = isConditionBlockType(log.blockType) - - const duration = log.durationMs || 0 - - let output = log.output || {} - let childWorkflowSnapshotId: string | undefined - let childWorkflowId: string | undefined - - if (output && typeof output === 'object') { - const outputRecord = output as Record - childWorkflowSnapshotId = - typeof outputRecord.childWorkflowSnapshotId === 'string' - ? outputRecord.childWorkflowSnapshotId - : undefined - childWorkflowId = - typeof outputRecord.childWorkflowId === 'string' ? outputRecord.childWorkflowId : undefined - if (childWorkflowSnapshotId || childWorkflowId) { - const { - childWorkflowSnapshotId: _childSnapshotId, - childWorkflowId: _childWorkflowId, - ...outputRest - } = outputRecord - output = outputRest - } - } - - if (log.error) { - output = { - ...output, - error: log.error, - } - } - - const displayName = log.blockName || log.blockId - - const span: TraceSpan = { - id: spanId, - name: displayName, - type: log.blockType, - duration: duration, - startTime: log.startedAt, - endTime: log.endedAt, - status: log.error ? 'error' : 'success', - children: [], - blockId: log.blockId, - input: log.input || {}, - output: output, - ...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}), - ...(childWorkflowId ? { childWorkflowId } : {}), - ...(log.errorHandled && { errorHandled: true }), - ...(log.loopId && { loopId: log.loopId }), - ...(log.parallelId && { parallelId: log.parallelId }), - ...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }), - ...(log.parentIterations?.length && { parentIterations: log.parentIterations }), - } - - if (!isCondition && log.output?.providerTiming) { - const providerTiming = log.output.providerTiming as { - duration: number - startTime: string - endTime: string - timeSegments?: Array<{ - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }> - } - - span.providerTiming = { - duration: providerTiming.duration, - startTime: providerTiming.startTime, - endTime: providerTiming.endTime, - segments: providerTiming.timeSegments || [], - } - } - - if (!isCondition && log.output?.cost) { - span.cost = log.output.cost as { - input?: number - output?: number - total?: number - } - } - - if (!isCondition && log.output?.tokens) { - const t = log.output.tokens as - | number - | { - input?: number - output?: number - total?: number - prompt?: number - completion?: number - } - if (typeof t === 'number') { - span.tokens = t - } else if (typeof t === 'object') { - const input = t.input ?? t.prompt - const output = t.output ?? t.completion - const total = - t.total ?? - (typeof input === 'number' || typeof output === 'number' - ? (input || 0) + (output || 0) - : undefined) - span.tokens = { - ...(typeof input === 'number' ? { input } : {}), - ...(typeof output === 'number' ? { output } : {}), - ...(typeof total === 'number' ? { total } : {}), - } - } else { - span.tokens = t - } - } - - if (!isCondition && log.output?.model) { - span.model = log.output.model as string - } - - if ( - !isWorkflowBlockType(log.blockType) && - !isCondition && - log.output?.providerTiming?.timeSegments && - Array.isArray(log.output.providerTiming.timeSegments) - ) { - const timeSegments = log.output.providerTiming.timeSegments - const toolCallsData = log.output?.toolCalls?.list || log.output?.toolCalls || [] - - const toolCallsByName = new Map>>() - for (const tc of toolCallsData as Array<{ name?: string; [key: string]: unknown }>) { - const normalizedName = stripCustomToolPrefix(tc.name || '') - if (!toolCallsByName.has(normalizedName)) { - toolCallsByName.set(normalizedName, []) - } - toolCallsByName.get(normalizedName)!.push(tc) - } - - const toolCallIndices = new Map() - - span.children = timeSegments.map( - ( - segment: { - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }, - index: number - ) => { - const segmentStartTime = new Date(segment.startTime).toISOString() - let segmentEndTime = new Date(segment.endTime).toISOString() - let segmentDuration = segment.duration - - if (segment.name?.toLowerCase().includes('streaming') && log.endedAt) { - const blockEndTime = new Date(log.endedAt).getTime() - const segmentEndTimeMs = new Date(segment.endTime).getTime() - - if (blockEndTime > segmentEndTimeMs) { - segmentEndTime = log.endedAt - segmentDuration = blockEndTime - new Date(segment.startTime).getTime() - } - } - - if (segment.type === 'tool') { - const normalizedName = stripCustomToolPrefix(segment.name || '') - - const toolCallsForName = toolCallsByName.get(normalizedName) || [] - const currentIndex = toolCallIndices.get(normalizedName) || 0 - const matchingToolCall = toolCallsForName[currentIndex] as - | { - error?: string - arguments?: Record - input?: Record - result?: Record - output?: Record - } - | undefined - - toolCallIndices.set(normalizedName, currentIndex + 1) - - return { - id: `${span.id}-segment-${index}`, - name: normalizedName, - type: 'tool', - duration: segment.duration, - startTime: segmentStartTime, - endTime: segmentEndTime, - status: matchingToolCall?.error ? 'error' : 'success', - input: matchingToolCall?.arguments || matchingToolCall?.input, - output: matchingToolCall?.error - ? { - error: matchingToolCall.error, - ...(matchingToolCall.result || matchingToolCall.output || {}), - } - : matchingToolCall?.result || matchingToolCall?.output, - } - } - return { - id: `${span.id}-segment-${index}`, - name: segment.name, - type: 'model', - duration: segmentDuration, - startTime: segmentStartTime, - endTime: segmentEndTime, - status: 'success', - } - } - ) - } else if (!isCondition) { - let toolCallsList = null - - try { - if (log.output?.toolCalls?.list) { - toolCallsList = log.output.toolCalls.list - } else if (Array.isArray(log.output?.toolCalls)) { - toolCallsList = log.output.toolCalls - } else if (log.output?.executionData?.output?.toolCalls) { - const tcObj = log.output.executionData.output.toolCalls - toolCallsList = Array.isArray(tcObj) ? tcObj : tcObj.list || [] - } - - if (toolCallsList && !Array.isArray(toolCallsList)) { - logger.warn(`toolCallsList is not an array: ${typeof toolCallsList}`, { - blockId: log.blockId, - blockType: log.blockType, - }) - toolCallsList = [] - } - } catch (error) { - logger.error(`Error extracting toolCalls from block ${log.blockId}:`, error) - toolCallsList = [] - } - - if (toolCallsList && toolCallsList.length > 0) { - const processedToolCalls: ToolCall[] = [] - - for (const tc of toolCallsList as Array<{ - name?: string - duration?: number - startTime?: string - endTime?: string - error?: string - arguments?: Record - input?: Record - result?: Record - output?: Record - }>) { - if (!tc) continue - - try { - const toolCall: ToolCall = { - name: stripCustomToolPrefix(tc.name || 'unnamed-tool'), - duration: tc.duration || 0, - startTime: tc.startTime || log.startedAt, - endTime: tc.endTime || log.endedAt, - status: tc.error ? 'error' : 'success', - } - - if (tc.arguments || tc.input) { - toolCall.input = tc.arguments || tc.input - } - - if (tc.result || tc.output) { - toolCall.output = tc.result || tc.output - } - - if (tc.error) { - toolCall.error = tc.error - } - - processedToolCalls.push(toolCall) - } catch (tcError) { - logger.error(`Error processing tool call in block ${log.blockId}:`, tcError) - } - } - - span.toolCalls = processedToolCalls - } - } - - if (isWorkflowBlockType(log.blockType)) { - const childTraceSpans = Array.isArray(log.childTraceSpans) - ? log.childTraceSpans - : Array.isArray(log.output?.childTraceSpans) - ? (log.output.childTraceSpans as TraceSpan[]) - : null - - if (childTraceSpans) { - const flattenedChildren = flattenWorkflowChildren(childTraceSpans) - span.children = flattenedChildren - - if (span.output && typeof span.output === 'object' && 'childTraceSpans' in span.output) { - const { childTraceSpans: _, ...cleanOutput } = span.output as { - childTraceSpans?: TraceSpan[] - } & Record - span.output = cleanOutput - } - } - } - - spanMap.set(spanId, span) - }) - - const sortedLogs = [...result.logs].sort((a, b) => { - const aTime = new Date(a.startedAt).getTime() - const bTime = new Date(b.startedAt).getTime() - return aTime - bTime - }) - - const rootSpans: TraceSpan[] = [] - - sortedLogs.forEach((log) => { - if (!log.blockId) return - - const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` - const span = spanMap.get(spanId) - if (span) { - rootSpans.push(span) - } - }) - - if (rootSpans.length === 0 && workflowConnections.length === 0) { - const spanStack: TraceSpan[] = [] - - sortedLogs.forEach((log) => { - if (!log.blockId || !log.blockType) return - - const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}` - const span = spanMap.get(spanId) - if (!span) return - - if (spanStack.length > 0) { - const potentialParent = spanStack[spanStack.length - 1] - const parentStartTime = new Date(potentialParent.startTime).getTime() - const parentEndTime = new Date(potentialParent.endTime).getTime() - const spanStartTime = new Date(span.startTime).getTime() - - if (spanStartTime >= parentStartTime && spanStartTime <= parentEndTime) { - if (!potentialParent.children) potentialParent.children = [] - potentialParent.children.push(span) - } else { - while ( - spanStack.length > 0 && - new Date(spanStack[spanStack.length - 1].endTime).getTime() < spanStartTime - ) { - spanStack.pop() - } + const spans = buildRootSpansFromLogs(result.logs) + const grouped = groupIterationBlocks(spans) - if (spanStack.length > 0) { - const newParent = spanStack[spanStack.length - 1] - if (!newParent.children) newParent.children = [] - newParent.children.push(span) - } else { - rootSpans.push(span) - } - } - } else { - rootSpans.push(span) - } - - if (log.blockType === 'agent' || isWorkflowBlockType(log.blockType)) { - spanStack.push(span) - } - }) + if (grouped.length === 0 || !result.metadata) { + const totalDuration = grouped.reduce((sum, span) => sum + span.duration, 0) + return { traceSpans: grouped, totalDuration } } - const groupedRootSpans = groupIterationBlocks(rootSpans) - - const totalDuration = groupedRootSpans.reduce((sum, span) => sum + span.duration, 0) - - if (groupedRootSpans.length > 0 && result.metadata) { - const allSpansList = Array.from(spanMap.values()) - - const earliestStart = allSpansList.reduce((earliest, span) => { - const startTime = new Date(span.startTime).getTime() - return startTime < earliest ? startTime : earliest - }, Number.POSITIVE_INFINITY) - - const latestEnd = allSpansList.reduce((latest, span) => { - const endTime = new Date(span.endTime).getTime() - return endTime > latest ? endTime : latest - }, 0) - - const actualWorkflowDuration = latestEnd - earliestStart - - const addRelativeTimestamps = (spans: TraceSpan[], workflowStartMs: number) => { - spans.forEach((span) => { - span.relativeStartMs = new Date(span.startTime).getTime() - workflowStartMs - if (span.children && span.children.length > 0) { - addRelativeTimestamps(span.children, workflowStartMs) - } - }) - } - addRelativeTimestamps(groupedRootSpans, earliestStart) - - const checkForUnhandledErrors = (s: TraceSpan): boolean => { - if (s.status === 'error' && !s.errorHandled) return true - return s.children ? s.children.some(checkForUnhandledErrors) : false - } - const hasUnhandledErrors = groupedRootSpans.some(checkForUnhandledErrors) - - const workflowSpan: TraceSpan = { - id: 'workflow-execution', - name: 'Workflow Execution', - type: 'workflow', - duration: actualWorkflowDuration, // Always use actual duration for the span - startTime: new Date(earliestStart).toISOString(), - endTime: new Date(latestEnd).toISOString(), - status: hasUnhandledErrors ? 'error' : 'success', - children: groupedRootSpans, - } + return wrapInWorkflowRoot(grouped, spans) +} - return { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration } +/** Converts each BlockLog into a TraceSpan, sorted chronologically by start time. */ +function buildRootSpansFromLogs(logs: BlockLog[]): TraceSpan[] { + const spans: TraceSpan[] = [] + for (const log of logs) { + const span = createSpanFromLog(log) + if (span) spans.push(span) } - - return { traceSpans: groupedRootSpans, totalDuration } + spans.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + return spans } /** - * Builds a container-level TraceSpan (iteration wrapper or top-level container) - * from its source spans and resolved children. + * Wraps grouped spans in a synthetic workflow-execution root span using the + * true workflow bounds (earliest start / latest end across all leaf spans). */ -function buildContainerSpan(opts: { - id: string - name: string - type: string - sourceSpans: TraceSpan[] - children: TraceSpan[] -}): TraceSpan { - const startTimes = opts.sourceSpans.map((s) => new Date(s.startTime).getTime()) - const endTimes = opts.sourceSpans.map((s) => new Date(s.endTime).getTime()) - const earliestStart = Math.min(...startTimes) - const latestEnd = Math.max(...endTimes) - - const hasErrors = opts.sourceSpans.some((s) => s.status === 'error') - const allErrorsHandled = - hasErrors && opts.children.every((s) => s.status !== 'error' || s.errorHandled) - - return { - id: opts.id, - name: opts.name, - type: opts.type, - duration: latestEnd - earliestStart, +function wrapInWorkflowRoot( + grouped: TraceSpan[], + leafSpans: TraceSpan[] +): { traceSpans: TraceSpan[]; totalDuration: number } { + let earliestStart = Number.POSITIVE_INFINITY + let latestEnd = 0 + for (const span of leafSpans) { + const startTime = new Date(span.startTime).getTime() + const endTime = new Date(span.endTime).getTime() + if (startTime < earliestStart) earliestStart = startTime + if (endTime > latestEnd) latestEnd = endTime + } + + const actualWorkflowDuration = latestEnd - earliestStart + addRelativeTimestamps(grouped, earliestStart) + + const totalCost = leafSpans.reduce((sum, s) => sum + (s.cost?.total ?? 0), 0) + + const workflowSpan: TraceSpan = { + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + duration: actualWorkflowDuration, startTime: new Date(earliestStart).toISOString(), endTime: new Date(latestEnd).toISOString(), - status: hasErrors ? 'error' : 'success', - ...(allErrorsHandled && { errorHandled: true }), - children: opts.children, + status: grouped.some(hasUnhandledError) ? 'error' : 'success', + children: grouped, + ...(totalCost > 0 && { cost: { total: totalCost } }), } -} -/** Counter state for generating sequential container names. */ -interface ContainerNameCounters { - loopNumbers: Map - parallelNumbers: Map - loopCounter: number - parallelCounter: number + return { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration } } -/** - * Resolves a container name from normal (non-iteration) spans or assigns a sequential number. - * Strips clone suffixes so all clones of the same container share one name/number. - */ -function resolveContainerName( - containerId: string, - containerType: 'parallel' | 'loop', - normalSpans: TraceSpan[], - counters: ContainerNameCounters -): string { - const originalId = stripCloneSuffixes(containerId) - - const matchingBlock = normalSpans.find( - (s) => s.blockId === originalId && s.type === containerType - ) - if (matchingBlock?.name) return matchingBlock.name - - if (containerType === 'parallel') { - if (!counters.parallelNumbers.has(originalId)) { - counters.parallelNumbers.set(originalId, counters.parallelCounter++) - } - return `Parallel ${counters.parallelNumbers.get(originalId)}` - } - if (!counters.loopNumbers.has(originalId)) { - counters.loopNumbers.set(originalId, counters.loopCounter++) - } - return `Loop ${counters.loopNumbers.get(originalId)}` -} - -/** - * Classifies a span's immediate container ID and type from its metadata. - * Returns undefined for non-iteration spans. - */ -function classifySpanContainer( - span: TraceSpan -): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { - if (span.parallelId) { - return { containerId: span.parallelId, containerType: 'parallel' } - } - if (span.loopId) { - return { containerId: span.loopId, containerType: 'loop' } - } - // Fallback: parse from blockId for legacy data - if (span.blockId?.includes('_parallel_')) { - const match = span.blockId.match(/_parallel_([^_]+)_iteration_/) - if (match) { - return { containerId: match[1], containerType: 'parallel' } - } - } - return undefined -} - -/** - * Finds the outermost container for a span. For nested spans, this is parentIterations[0]. - * For flat spans, this is the span's own immediate container. - */ -function getOutermostContainer( - span: TraceSpan -): { containerId: string; containerType: 'parallel' | 'loop' } | undefined { - if (span.parentIterations && span.parentIterations.length > 0) { - const outermost = span.parentIterations[0] - return { - containerId: outermost.iterationContainerId, - containerType: outermost.iterationType as 'parallel' | 'loop', - } - } - return classifySpanContainer(span) -} - -/** - * Builds the iteration-level hierarchy for a container, recursively nesting - * any deeper subflows. Works with both: - * - Direct spans (spans whose immediate container matches) - * - Nested spans (spans with parentIterations pointing through this container) - */ -function buildContainerChildren( - containerType: 'parallel' | 'loop', - containerId: string, - spans: TraceSpan[], - normalSpans: TraceSpan[], - counters: ContainerNameCounters -): TraceSpan[] { - const iterationType = containerType === 'parallel' ? 'parallel-iteration' : 'loop-iteration' - - // Group spans by iteration index at this level. - // Each span's iteration index at this level comes from: - // - parentIterations[0].iterationCurrent if parentIterations[0].containerId === containerId - // - span.iterationIndex if span's immediate container === containerId - const iterationGroups = new Map() - - for (const span of spans) { - let iterIdx: number | undefined - - if ( - span.parentIterations && - span.parentIterations.length > 0 && - span.parentIterations[0].iterationContainerId === containerId - ) { - iterIdx = span.parentIterations[0].iterationCurrent - } else { - // The span's immediate container is this container - iterIdx = span.iterationIndex - } - - if (iterIdx === undefined) continue - - if (!iterationGroups.has(iterIdx)) iterationGroups.set(iterIdx, []) - iterationGroups.get(iterIdx)!.push(span) - } - - const iterationChildren: TraceSpan[] = [] - const sortedIterations = Array.from(iterationGroups.entries()).sort(([a], [b]) => a - b) - - for (const [iterationIndex, iterSpans] of sortedIterations) { - // For each span in this iteration, strip one level of ancestry and determine - // whether it belongs to this container directly or to a deeper subflow - const directLeaves: TraceSpan[] = [] - const deeperSpans: TraceSpan[] = [] - - for (const span of iterSpans) { - if ( - span.parentIterations && - span.parentIterations.length > 0 && - span.parentIterations[0].iterationContainerId === containerId - ) { - // Strip the outermost parentIteration (this container level) - deeperSpans.push({ - ...span, - parentIterations: span.parentIterations.slice(1), - }) - } else { - // This span's immediate container IS this container — it's a direct leaf - directLeaves.push({ - ...span, - name: span.name.replace(/ \(iteration \d+\)$/, ''), - }) - } - } - - // Recursively group the deeper spans (they'll form nested containers) - const nestedResult = groupIterationBlocksRecursive( - [...directLeaves, ...deeperSpans], - normalSpans, - counters - ) - - iterationChildren.push( - buildContainerSpan({ - id: `${containerId}-iteration-${iterationIndex}`, - name: `Iteration ${iterationIndex}`, - type: iterationType, - sourceSpans: iterSpans, - children: nestedResult, - }) - ) - } - - return iterationChildren -} - -/** - * Core recursive algorithm for grouping iteration blocks. - * - * Handles two cases: - * 1. **Flat** (backward compat): spans have loopId/parallelId + iterationIndex but no - * parentIterations. Grouped by immediate container → iteration → leaf. - * 2. **Nested** (new): spans have parentIterations chains. The outermost ancestor in the - * chain determines the top-level container. Iteration spans are peeled one level at a - * time and recursed. - * - * Container BlockLogs (parallel/loop) are produced on skip (empty collection), error, and - * successful completion. When present, they supply the user-configured container name via - * `resolveContainerName`; otherwise the container is synthesized from iteration data with a - * counter-based fallback name. - */ -function groupIterationBlocksRecursive( - spans: TraceSpan[], - normalSpans: TraceSpan[], - counters: ContainerNameCounters -): TraceSpan[] { - const result: TraceSpan[] = [] - const iterationSpans: TraceSpan[] = [] - const nonIterationSpans: TraceSpan[] = [] - +/** Recursively annotates spans with `relativeStartMs` (ms since workflow start). */ +function addRelativeTimestamps(spans: TraceSpan[], workflowStartMs: number): void { for (const span of spans) { - if ( - span.name.match(/^(.+) \(iteration (\d+)\)$/) || - (span.parentIterations && span.parentIterations.length > 0) - ) { - iterationSpans.push(span) - } else { - nonIterationSpans.push(span) - } - } - - const containerIdsWithIterations = new Set() - for (const span of iterationSpans) { - const outermost = getOutermostContainer(span) - if (outermost) containerIdsWithIterations.add(outermost.containerId) - } - - const nonContainerSpans = nonIterationSpans.filter( - (span) => - (span.type !== 'parallel' && span.type !== 'loop') || - span.status === 'error' || - (span.blockId && !containerIdsWithIterations.has(span.blockId)) - ) - - if (iterationSpans.length === 0) { - result.push(...nonContainerSpans) - result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) - return result - } - - // Group iteration spans by outermost container - const containerGroups = new Map< - string, - { type: 'parallel' | 'loop'; containerId: string; containerName: string; spans: TraceSpan[] } - >() - - for (const span of iterationSpans) { - const outermost = getOutermostContainer(span) - if (!outermost) continue - - const { containerId, containerType } = outermost - const groupKey = `${containerType}_${containerId}` - - if (!containerGroups.has(groupKey)) { - const containerName = resolveContainerName(containerId, containerType, normalSpans, counters) - containerGroups.set(groupKey, { - type: containerType, - containerId, - containerName, - spans: [], - }) + span.relativeStartMs = new Date(span.startTime).getTime() - workflowStartMs + if (span.children?.length) { + addRelativeTimestamps(span.children, workflowStartMs) } - containerGroups.get(groupKey)!.spans.push(span) - } - - // Build each container with recursive nesting - for (const [, group] of containerGroups) { - const { type, containerId, containerName, spans: containerSpans } = group - - const iterationChildren = buildContainerChildren( - type, - containerId, - containerSpans, - normalSpans, - counters - ) - - result.push( - buildContainerSpan({ - id: `${type === 'parallel' ? 'parallel' : 'loop'}-execution-${containerId}`, - name: containerName, - type, - sourceSpans: containerSpans, - children: iterationChildren, - }) - ) } - - result.push(...nonContainerSpans) - result.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) - - return result } -/** - * Groups iteration-based blocks (parallel and loop) by organizing their iteration spans - * into a hierarchical structure with proper parent-child relationships. - * Supports recursive nesting via parentIterations (e.g., parallel-in-parallel, loop-in-loop). - * - * @param spans - Array of root spans to process - * @returns Array of spans with iteration blocks properly grouped - */ -function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] { - const normalSpans = spans.filter((s) => !s.name.match(/^(.+) \(iteration (\d+)\)$/)) - const counters: ContainerNameCounters = { - loopNumbers: new Map(), - parallelNumbers: new Map(), - loopCounter: 1, - parallelCounter: 1, - } - return groupIterationBlocksRecursive(spans, normalSpans, counters) +/** True if this span (or any descendant) has an unhandled error. */ +function hasUnhandledError(span: TraceSpan): boolean { + if (span.status === 'error' && !span.errorHandled) return true + return span.children?.some(hasUnhandledError) ?? false } diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index 20f568ab41c..e64fc91d56c 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -1,7 +1,13 @@ import type { Edge } from 'reactflow' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import type { ParentIteration, SerializableExecutionState } from '@/executor/execution/types' -import type { BlockLog, NormalizedBlockOutput } from '@/executor/types' +import type { + BlockLog, + BlockTokens, + IterationToolCall, + NormalizedBlockOutput, + ProviderTimingSegment, +} from '@/executor/types' import type { Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' export type { WorkflowState, Loop, Parallel } @@ -149,6 +155,7 @@ export interface WorkflowExecutionLog { > executionState?: SerializableExecutionState finalOutput?: any + workflowInput?: unknown errorDetails?: { blockId: string blockName: string @@ -179,25 +186,13 @@ export interface WorkflowExecutionLog { export type WorkflowExecutionLogInsert = Omit export type WorkflowExecutionLogSelect = WorkflowExecutionLog -export interface TokenInfo { - input?: number - output?: number - total?: number - prompt?: number - completion?: number -} +export type TokenInfo = BlockTokens export interface ProviderTiming { duration: number startTime: string endTime: string - segments: Array<{ - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }> + segments: ProviderTimingSegment[] } export interface TraceSpan { @@ -208,11 +203,15 @@ export interface TraceSpan { startTime: string endTime: string children?: TraceSpan[] + /** + * @deprecated Tool invocations are emitted as `children` with `type: 'tool'`. + * This field only appears on legacy trace spans persisted before the unification. + */ toolCalls?: ToolCall[] status?: 'success' | 'error' /** Whether this block's error was handled by an error handler path */ errorHandled?: boolean - tokens?: number | TokenInfo + tokens?: TokenInfo relativeStartMs?: number blockId?: string input?: Record @@ -230,6 +229,43 @@ export interface TraceSpan { parallelId?: string iterationIndex?: number parentIterations?: ParentIteration[] + /** + * For model child spans: the assistant's thinking/reasoning blocks from this + * iteration, stringified. Surfaces Anthropic extended thinking and equivalents. + */ + thinking?: string + /** + * For model child spans: the tool calls the assistant requested in this + * iteration. `id` is the provider-assigned `tool_call.id`, used to correlate + * the following tool child span via its `toolCallId` field. + */ + modelToolCalls?: IterationToolCall[] + /** + * For model child spans: the provider-reported stop reason + * (`stop`, `tool_use`, `length`, …). + */ + finishReason?: string + /** + * For tool child spans: the `tool_call.id` this tool invocation satisfies. + * Matches one of the preceding model child's `modelToolCalls[i].id`. + */ + toolCallId?: string + /** + * For model child spans: time-to-first-token in ms (streaming runs only). + */ + ttft?: number + /** + * For model child spans: the provider system identifier + * (`anthropic`, `openai`, `gemini`, …) — aligns with OTel `gen_ai.system`. + */ + provider?: string + /** + * For failed child spans: structured error class + * (e.g. `rate_limit`, `context_length`). + */ + errorType?: string + /** For failed child spans: human-readable error message. */ + errorMessage?: string } export interface WorkflowExecutionSummary { diff --git a/apps/sim/lib/tokenization/streaming.ts b/apps/sim/lib/tokenization/streaming.ts index 047fd0b8b38..ca552fa8292 100644 --- a/apps/sim/lib/tokenization/streaming.ts +++ b/apps/sim/lib/tokenization/streaming.ts @@ -49,13 +49,19 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string) const inputText = extractTextContent(log.input) // Calculate streaming cost + const systemPrompt = + typeof log.input?.systemPrompt === 'string' ? log.input.systemPrompt : undefined + const context = typeof log.input?.context === 'string' ? log.input.context : undefined + const messages = Array.isArray(log.input?.messages) + ? (log.input.messages as Array<{ role: string; content: string }>) + : undefined const result = calculateStreamingCost( model, inputText, streamedContent, - log.input?.systemPrompt, - log.input?.context, - log.input?.messages + systemPrompt, + context, + messages ) // Update the log output with tokenization data @@ -102,8 +108,9 @@ function getModelForBlock(log: BlockLog): string { } // Try to get model from input - if (log.input?.model?.trim()) { - return log.input.model + const inputModel = log.input?.model + if (typeof inputModel === 'string' && inputModel.trim()) { + return inputModel } // Use block type specific defaults diff --git a/apps/sim/lib/tokenization/utils.ts b/apps/sim/lib/tokenization/utils.ts index e3c3c3287d0..72bcf9ac420 100644 --- a/apps/sim/lib/tokenization/utils.ts +++ b/apps/sim/lib/tokenization/utils.ts @@ -11,6 +11,7 @@ import { } from '@/lib/tokenization/constants' import { createTokenizationError } from '@/lib/tokenization/errors' import type { ProviderTokenizationConfig, TokenUsage } from '@/lib/tokenization/types' +import type { BlockTokens } from '@/executor/types' import { getProviderFromModel } from '@/providers/utils' const logger = createLogger('TokenizationUtils') @@ -56,9 +57,11 @@ export function isTokenizableBlockType(blockType?: string): boolean { /** * Checks if tokens/cost data is meaningful (non-zero) */ -export function hasRealTokenData(tokens?: TokenUsage): boolean { +export function hasRealTokenData( + tokens?: Pick +): boolean { if (!tokens) return false - return tokens.total > 0 || tokens.input > 0 || tokens.output > 0 + return (tokens.total ?? 0) > 0 || (tokens.input ?? 0) > 0 || (tokens.output ?? 0) > 0 } /** diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index c51d1420188..bda5c2f6f4a 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,7 +3,7 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import type { StreamingExecution } from '@/executor/types' +import type { BlockTokens, IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { checkForForcedToolUsage, @@ -15,6 +15,7 @@ import { supportsNativeStructuredOutputs, supportsTemperature, } from '@/providers/models' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' import { @@ -446,7 +447,7 @@ export async function executeAnthropicProviderRequest( timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -516,7 +517,7 @@ export async function executeAnthropicProviderRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -546,6 +547,11 @@ export async function executeAnthropicProviderRequest( } const toolUses = currentResponse.content.filter((item) => item.type === 'tool_use') + + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { + model: request.model, + }) + if (!toolUses || toolUses.length === 0) { break } @@ -622,6 +628,7 @@ export async function executeAnthropicProviderRequest( startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolUse.id, }) let resultContent: unknown @@ -751,7 +758,7 @@ export async function executeAnthropicProviderRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -768,6 +775,16 @@ export async function executeAnthropicProviderRequest( iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + const trailingText = currentResponse.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n') + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { + model: request.model, + }) + } } catch (error) { logger.error(`Error in ${providerLabel} request:`, { error }) throw error @@ -930,7 +947,7 @@ export async function executeAnthropicProviderRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -960,6 +977,11 @@ export async function executeAnthropicProviderRequest( } const toolUses = currentResponse.content.filter((item) => item.type === 'tool_use') + + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { + model: request.model, + }) + if (!toolUses || toolUses.length === 0) { break } @@ -1038,6 +1060,7 @@ export async function executeAnthropicProviderRequest( startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolUseId, }) let resultContent: unknown @@ -1165,7 +1188,7 @@ export async function executeAnthropicProviderRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -1191,6 +1214,16 @@ export async function executeAnthropicProviderRequest( iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + const trailingText = currentResponse.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n') + enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { + model: request.model, + }) + } } catch (error) { logger.error(`Error in ${providerLabel} request:`, { error }) throw error @@ -1336,3 +1369,87 @@ export async function executeAnthropicProviderRequest( }) } } + +/** + * Enriches the last model segment with content from an Anthropic `Message`: + * assistant text, thinking/redacted_thinking blocks, tool_use calls (with IDs), + * stop_reason, and per-iteration tokens. + */ +function enrichLastModelSegmentFromAnthropicResponse( + timeSegments: TimeSegment[], + response: Anthropic.Messages.Message, + textContent: string, + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const thinkingBlocks = response.content.filter( + (item): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => + item.type === 'thinking' || item.type === 'redacted_thinking' + ) + const thinkingContent = thinkingBlocks + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + + const toolUseBlocks = response.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + const toolCalls: IterationToolCall[] = toolUseBlocks.map((t) => ({ + id: t.id, + name: t.name, + arguments: + t.input && typeof t.input === 'object' && !Array.isArray(t.input) + ? (t.input as Record) + : {}, + })) + + const segmentTokens = response.usage ? buildAnthropicSegmentTokens(response.usage) : undefined + + let cost: { input: number; output: number; total: number } | undefined + if ( + extras?.model && + segmentTokens && + typeof segmentTokens.input === 'number' && + typeof segmentTokens.output === 'number' + ) { + const useCached = (segmentTokens.cacheRead ?? 0) > 0 + const full = calculateCost(extras.model, segmentTokens.input, segmentTokens.output, useCached) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: textContent || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: response.stop_reason ?? undefined, + tokens: segmentTokens, + cost, + provider: 'anthropic', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} + +/** + * Builds a segment token breakdown from Anthropic usage data, surfacing prompt + * cache reads/writes separately and producing a corrected `total` that includes + * cache_creation tokens (which Anthropic bills as input tokens but omits from + * `input_tokens`). + */ +function buildAnthropicSegmentTokens(usage: Anthropic.Messages.Message['usage']): BlockTokens { + const input = usage.input_tokens ?? 0 + const output = usage.output_tokens ?? 0 + const cacheRead = usage.cache_read_input_tokens ?? 0 + const cacheWrite = usage.cache_creation_input_tokens ?? 0 + return { + input, + output, + total: input + output + cacheRead + cacheWrite, + ...(cacheRead > 0 && { cacheRead }), + ...(cacheWrite > 0 && { cacheWrite }), + } +} diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index d60354c77af..a5c9fcd633f 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -25,6 +25,7 @@ import { } from '@/providers/azure-openai/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeResponsesProviderRequest } from '@/providers/openai/core' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, ProviderConfig, @@ -223,7 +224,7 @@ async function executeChatCompletionsRequest( timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -272,13 +273,20 @@ async function executeChatCompletionsRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, }, ] + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'azure_openai' } + ) + const firstCheckResult = checkForForcedToolUsage( currentResponse, originalToolChoice ?? 'auto', @@ -450,12 +458,19 @@ async function executeChatCompletionsRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, }) + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'azure_openai' } + ) + modelTime += thisModelTime if (currentResponse.choices[0]?.message?.content) { diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index f054d781999..31c8d14cfc6 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -5,6 +5,7 @@ import { type ContentBlock, type ConversationRole, ConverseCommand, + type ConverseResponse, ConverseStreamCommand, type SystemContentBlock, type Tool, @@ -14,7 +15,7 @@ import { } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import type { StreamingExecution } from '@/executor/types' +import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { checkForForcedToolUsage, @@ -23,6 +24,7 @@ import { getBedrockInferenceProfileId, } from '@/providers/bedrock/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, ProviderConfig, @@ -41,6 +43,62 @@ import { executeTool } from '@/tools' const logger = createLogger('BedrockProvider') +function enrichLastModelSegmentFromBedrockResponse( + timeSegments: TimeSegment[], + response: ConverseResponse, + extras: { model: string } +): void { + const blocks: ContentBlock[] = response.output?.message?.content ?? [] + + const assistantText = blocks + .filter((b): b is ContentBlock & { text: string } => 'text' in b && typeof b.text === 'string') + .map((b) => b.text) + .join('\n') + const assistantContent = assistantText.length > 0 ? assistantText : undefined + + const toolCalls: IterationToolCall[] = blocks + .filter((b): b is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in b && !!b.toolUse) + .map((b) => { + const input = b.toolUse.input + return { + id: b.toolUse.toolUseId ?? '', + name: b.toolUse.name ?? '', + arguments: + input && typeof input === 'object' && !Array.isArray(input) + ? (input as Record) + : {}, + } + }) + + const inputTokens = response.usage?.inputTokens + const outputTokens = response.usage?.outputTokens + + let cost: { input: number; output: number; total: number } | undefined + if (typeof inputTokens === 'number' && typeof outputTokens === 'number') { + const full = calculateCost(extras.model, inputTokens, outputTokens) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: response.stopReason ?? undefined, + tokens: + inputTokens !== undefined || outputTokens !== undefined + ? { + input: inputTokens, + output: outputTokens, + total: + typeof inputTokens === 'number' && typeof outputTokens === 'number' + ? inputTokens + outputTokens + : undefined, + } + : undefined, + cost, + provider: 'aws.bedrock', + }) +} + export const bedrockProvider: ProviderConfig = { id: 'bedrock', name: 'AWS Bedrock', @@ -345,7 +403,7 @@ export const bedrockProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -444,13 +502,17 @@ export const bedrockProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, }, ] + enrichLastModelSegmentFromBedrockResponse(timeSegments, currentResponse, { + model: request.model, + }) + const initialToolUseContentBlocks = (currentResponse.output?.message?.content || []).filter( (block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block ) @@ -668,12 +730,16 @@ export const bedrockProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, }) + enrichLastModelSegmentFromBedrockResponse(timeSegments, currentResponse, { + model: request.model, + }) + modelTime += thisModelTime if (currentResponse.usage) { @@ -725,6 +791,10 @@ export const bedrockProvider: ProviderConfig = { duration: structuredOutputEndTime - structuredOutputStartTime, }) + enrichLastModelSegmentFromBedrockResponse(timeSegments, structuredResponse, { + model: request.model, + }) + modelTime += structuredOutputEndTime - structuredOutputStartTime const structuredOutputCall = structuredResponse.output?.message?.content?.find( diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 2bdfcdc1722..fe6f0bba76c 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -6,6 +6,7 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import type { CerebrasResponse } from '@/providers/cerebras/types' import { createReadableStreamFromCerebrasStream } from '@/providers/cerebras/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -161,7 +162,7 @@ export const cerebrasProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -206,7 +207,7 @@ export const cerebrasProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -219,6 +220,13 @@ export const cerebrasProvider: ProviderConfig = { while (iterationCount < MAX_TOOL_ITERATIONS) { const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'cerebras' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { if (currentResponse.choices[0]?.message?.content) { content = currentResponse.choices[0].message.content @@ -313,6 +321,7 @@ export const cerebrasProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any if (result.success && result.output) { @@ -382,7 +391,7 @@ export const cerebrasProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: 'Final response', + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -399,6 +408,13 @@ export const cerebrasProvider: ProviderConfig = { tokens.total += finalResponse.usage.total_tokens || 0 } + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'cerebras' } + ) + break } @@ -419,7 +435,7 @@ export const cerebrasProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -435,6 +451,15 @@ export const cerebrasProvider: ProviderConfig = { iterationCount++ } } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'cerebras' } + ) + } } catch (error) { logger.error('Error in Cerebras tool processing:', { error }) } @@ -564,3 +589,8 @@ export const cerebrasProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index bd4abf1ace4..6f5c0612e3d 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -5,6 +5,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -161,7 +162,7 @@ export const deepseekProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -217,7 +218,7 @@ export const deepseekProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -248,6 +249,14 @@ export const deepseekProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'deepseek' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -324,6 +333,7 @@ export const deepseekProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -410,7 +420,7 @@ export const deepseekProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -432,6 +442,15 @@ export const deepseekProvider: ProviderConfig = { iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'deepseek' } + ) + } } catch (error) { logger.error('Error in Deepseek request:', { error }) } diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index 08d24584f96..6aa336ec7b9 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -10,6 +10,7 @@ import { supportsNativeStructuredOutputs, } from '@/providers/fireworks/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, Message, @@ -209,7 +210,7 @@ export const fireworksProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -257,7 +258,7 @@ export const fireworksProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -279,6 +280,14 @@ export const fireworksProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'fireworks' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -358,6 +367,7 @@ export const fireworksProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -423,7 +433,7 @@ export const fireworksProvider: ProviderConfig = { const thisModelTime = nextModelEndTime - nextModelStartTime timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -440,6 +450,15 @@ export const fireworksProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'fireworks' } + ) + } + if (request.stream) { const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) @@ -572,6 +591,13 @@ export const fireworksProvider: ProviderConfig = { tokens.output += finalResponse.usage.completion_tokens || 0 tokens.total += finalResponse.usage.total_tokens || 0 } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'fireworks' } + ) } const providerEndTime = Date.now() @@ -622,3 +648,8 @@ export const fireworksProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 786975eabcc..e22baeda8e7 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -13,7 +13,7 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import type { StreamingExecution } from '@/executor/types' +import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { checkForForcedToolUsage, @@ -26,7 +26,13 @@ import { extractTextContent, mapToThinkingLevel, } from '@/providers/google/utils' -import type { FunctionCallResponse, ProviderRequest, ProviderResponse } from '@/providers/types' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { + FunctionCallResponse, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' import { calculateCost, isDeepResearchModel, @@ -71,7 +77,7 @@ function createInitialState( timeSegments: [ { type: 'model', - name: 'Initial response', + name: model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -218,6 +224,7 @@ async function executeToolCallsBatch( startTime: r.startTime, endTime: r.endTime, duration: r.duration, + toolCallId: r.part.functionCall?.id ?? undefined, }) totalToolsTime += r.duration @@ -279,7 +286,7 @@ function updateStateWithResponse( ...state.timeSegments, { type: 'model', - name: `Model response (iteration ${state.iterationCount + 1})`, + name: model, startTime, endTime, duration, @@ -1074,6 +1081,9 @@ export async function executeGeminiRequest( model, toolConfig ) + enrichLastModelSegmentFromGeminiResponse(state.timeSegments, response, { + model, + }) const forcedTools = preparedTools?.forcedTools ?? [] let currentResponse = response @@ -1122,6 +1132,9 @@ export async function executeGeminiRequest( config: nextConfig, }) state = updateStateWithResponse(state, checkResponse, model, Date.now() - 100, Date.now()) + enrichLastModelSegmentFromGeminiResponse(state.timeSegments, checkResponse, { + model, + }) if (checkResponse.functionCalls?.length) { currentResponse = checkResponse @@ -1207,6 +1220,9 @@ export async function executeGeminiRequest( config: nextConfig, }) state = updateStateWithResponse(state, nextResponse, model, nextModelStartTime, Date.now()) + enrichLastModelSegmentFromGeminiResponse(state.timeSegments, nextResponse, { + model, + }) currentResponse = nextResponse } @@ -1257,3 +1273,80 @@ export async function executeGeminiRequest( throw enhancedError } } + +/** + * Enriches the last model segment with per-iteration content extracted from a + * Gemini response: assistant text, thinking (thought) parts, function calls, + * finish reason, and token usage. + */ +function enrichLastModelSegmentFromGeminiResponse( + timeSegments: TimeSegment[], + response: GenerateContentResponse, + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const candidate = response.candidates?.[0] + const assistantText = extractTextContent(candidate) + + const thinkingParts = + candidate?.content?.parts?.filter((p): p is Part & { text: string } => + Boolean(p.text && p.thought === true) + ) ?? [] + const thinkingContent = thinkingParts.map((p) => p.text).join('\n\n') + + const functionCallParts = extractAllFunctionCallParts(candidate) + const toolCalls: IterationToolCall[] = functionCallParts + .filter((p): p is Part & { functionCall: NonNullable } => + Boolean(p.functionCall) + ) + .map((p) => ({ + id: p.functionCall.id ?? '', + name: p.functionCall.name ?? '', + arguments: (p.functionCall.args ?? {}) as Record, + })) + + const usage = convertUsageMetadata(response.usageMetadata) + const cachedContentTokens = response.usageMetadata?.cachedContentTokenCount ?? 0 + const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount ?? 0 + + let cost: { input: number; output: number; total: number } | undefined + if ( + extras?.model && + response.usageMetadata && + typeof usage.promptTokenCount === 'number' && + typeof usage.candidatesTokenCount === 'number' + ) { + const full = calculateCost( + extras.model, + usage.promptTokenCount, + usage.candidatesTokenCount, + cachedContentTokens > 0 + ) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: candidate?.finishReason ?? undefined, + tokens: response.usageMetadata + ? { + input: usage.promptTokenCount, + output: usage.candidatesTokenCount, + total: usage.totalTokenCount, + ...(cachedContentTokens > 0 && { cacheRead: cachedContentTokens }), + ...(thoughtsTokens > 0 && { reasoning: thoughtsTokens }), + } + : undefined, + cost, + provider: 'google', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index fba8984e86b..192e1412d94 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -5,6 +5,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -162,7 +163,7 @@ export const groqProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -212,7 +213,7 @@ export const groqProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -226,6 +227,14 @@ export const groqProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'groq' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -302,6 +311,7 @@ export const groqProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -373,7 +383,7 @@ export const groqProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -393,6 +403,15 @@ export const groqProvider: ProviderConfig = { iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'groq' } + ) + } } catch (error) { logger.error('Error in Groq request:', { error }) } diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 32e24c1f329..ffe1ecad930 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -6,6 +6,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -200,7 +201,7 @@ export const mistralProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -272,7 +273,7 @@ export const mistralProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -287,6 +288,14 @@ export const mistralProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'mistral' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -365,6 +374,7 @@ export const mistralProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -433,7 +443,7 @@ export const mistralProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -454,6 +464,15 @@ export const mistralProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'mistral' } + ) + } + if (request.stream) { logger.info('Using streaming for final response after tool processing') @@ -576,3 +595,8 @@ export const mistralProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 45ea3802b9c..045dd1d462a 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -7,6 +7,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import type { ModelsObject } from '@/providers/ollama/types' import { createReadableStreamFromOllamaStream } from '@/providers/ollama/utils' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderConfig, ProviderRequest, @@ -230,7 +231,7 @@ export const ollamaProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -282,7 +283,7 @@ export const ollamaProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -295,6 +296,14 @@ export const ollamaProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'ollama' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -375,6 +384,7 @@ export const ollamaProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -426,7 +436,7 @@ export const ollamaProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -449,6 +459,15 @@ export const ollamaProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'ollama' } + ) + } + if (request.stream) { logger.info('Using streaming for final response after tool processing') @@ -579,3 +598,8 @@ export const ollamaProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 6a0104e0651..1f025269235 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -1,8 +1,9 @@ import type { Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type OpenAI from 'openai' -import type { StreamingExecution } from '@/executor/types' +import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' import { @@ -18,6 +19,7 @@ import { convertResponseOutputToInputItems, convertToolsToResponses, createReadableStreamFromResponses, + extractResponseReasoning, extractResponseText, extractResponseToolCalls, parseResponsesUsage, @@ -347,7 +349,7 @@ export async function executeResponsesProviderRequest( timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -416,7 +418,7 @@ export async function executeResponsesProviderRequest( const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -435,6 +437,15 @@ export async function executeResponsesProviderRequest( } const toolCallsInResponse = extractResponseToolCalls(currentResponse.output) + + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + currentResponse, + responseText, + toolCallsInResponse, + { model: request.model } + ) + if (!toolCallsInResponse.length) { break } @@ -511,6 +522,7 @@ export async function executeResponsesProviderRequest( startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: Record @@ -586,7 +598,7 @@ export async function executeResponsesProviderRequest( timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -604,6 +616,18 @@ export async function executeResponsesProviderRequest( iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + const trailingText = extractResponseText(currentResponse.output) + const trailingToolCalls = extractResponseToolCalls(currentResponse.output) + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + currentResponse, + trailingText, + trailingToolCalls, + { model: request.model } + ) + } + // For Azure with deferred format: make a final call with the response format applied // This happens whenever we have a deferred format, even if no tools were called // (the initial call was made without the format, so we need to apply it now) @@ -685,6 +709,14 @@ export async function executeResponsesProviderRequest( content = formattedText } + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + currentResponse, + formattedText, + extractResponseToolCalls(currentResponse.output), + { model: request.model } + ) + appliedDeferredFormat = true } @@ -821,3 +853,82 @@ export async function executeResponsesProviderRequest( }) } } + +/** + * Determines a finish reason for an OpenAI Responses API response. + * Maps to conventional values: 'tool_calls' | 'length' | 'stop'. + */ +function deriveOpenAIFinishReason( + response: OpenAI.Responses.Response, + toolCalls: ResponsesToolCall[] +): string | undefined { + const incompleteReason = response.incomplete_details?.reason + if (incompleteReason === 'max_output_tokens') return 'length' + if (incompleteReason === 'content_filter') return 'content_filter' + if (toolCalls.length > 0) return 'tool_calls' + if (incompleteReason) return incompleteReason + if (response.status === 'failed') return 'error' + if (response.status === 'incomplete') return 'length' + if (response.status && response.status !== 'completed') return response.status + return 'stop' +} + +/** + * Enriches the last model segment with per-iteration content extracted from an + * OpenAI Responses API response: assistant text, tool calls, finish reason, + * and token usage for the iteration. + */ +function enrichLastModelSegmentFromOpenAIResponse( + timeSegments: TimeSegment[], + response: OpenAI.Responses.Response, + assistantText: string, + toolCallsInResponse: ResponsesToolCall[], + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const toolCalls: IterationToolCall[] = toolCallsInResponse.map((tc) => ({ + id: tc.id, + name: tc.name, + arguments: + typeof tc.arguments === 'string' ? parseToolCallArguments(tc.arguments) : tc.arguments, + })) + + const usage = parseResponsesUsage(response.usage) + const thinkingContent = extractResponseReasoning(response.output) + + let cost: { input: number; output: number; total: number } | undefined + if (extras?.model && usage) { + const full = calculateCost( + extras.model, + usage.promptTokens, + usage.completionTokens, + usage.cachedTokens > 0 + ) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: deriveOpenAIFinishReason(response, toolCallsInResponse), + tokens: usage + ? { + input: usage.promptTokens, + output: usage.completionTokens, + total: usage.totalTokens, + ...(usage.cachedTokens > 0 && { cacheRead: usage.cachedTokens }), + ...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }), + } + : undefined, + cost, + provider: 'openai', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index f1575473ada..88efec06e2e 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -199,6 +199,29 @@ export function extractResponseText(output: OpenAI.Responses.ResponseOutputItem[ return textParts.join('') } +/** + * Extracts reasoning summary text from Responses API output items. Reasoning + * items (emitted by o1/o3/gpt-5) carry a `summary[]` of `{ type, text }` entries + * — we join the text for trace display. The raw `encrypted_content` is left + * alone; it's opaque plumbing for round-tripping across turns. + */ +export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutputItem[]): string { + if (!Array.isArray(output)) return '' + + const parts: string[] = [] + for (const item of output) { + if (!item || item.type !== 'reasoning') continue + const summary = (item as unknown as { summary?: Array<{ text?: string | null } | null> }) + .summary + if (!Array.isArray(summary)) continue + for (const entry of summary) { + const text = entry?.text + if (typeof text === 'string' && text.length > 0) parts.push(text) + } + } + return parts.join('\n\n') +} + /** * Converts Responses API output items into input items for subsequent calls. */ diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 89ae932c32d..87ff07fcfef 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -10,6 +10,7 @@ import { createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { FunctionCallResponse, Message, @@ -210,7 +211,7 @@ export const openRouterProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -258,7 +259,7 @@ export const openRouterProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -280,6 +281,14 @@ export const openRouterProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'openrouter' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -359,6 +368,7 @@ export const openRouterProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -424,7 +434,7 @@ export const openRouterProvider: ProviderConfig = { const thisModelTime = nextModelEndTime - nextModelStartTime timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -441,6 +451,15 @@ export const openRouterProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'openrouter' } + ) + } + if (request.stream) { const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) @@ -573,6 +592,13 @@ export const openRouterProvider: ProviderConfig = { tokens.output += finalResponse.usage.completion_tokens || 0 tokens.total += finalResponse.usage.total_tokens || 0 } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'openrouter' } + ) } const providerEndTime = Date.now() @@ -623,3 +649,8 @@ export const openRouterProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts new file mode 100644 index 00000000000..0d3c3232b28 --- /dev/null +++ b/apps/sim/providers/trace-enrichment.ts @@ -0,0 +1,221 @@ +import type { BlockTokens, IterationToolCall, ProviderTimingSegment } from '@/executor/types' +import { calculateCost } from '@/providers/utils' + +/** + * Minimal structural shape shared by OpenAI Chat Completions and every + * OpenAI-compatible SDK (Groq, Cerebras, DeepSeek, xAI, Mistral, Ollama, + * OpenRouter, vLLM, Fireworks). Captures only the fields the trace enrichment + * helper reads, so providers can pass their own SDK's response type without + * a cast. + */ +interface ChatCompletionLike { + choices: Array<{ + message?: { + content?: string | null + tool_calls?: Array | null + } | null + finish_reason?: string | null + } | null> + usage?: { + prompt_tokens?: number | null + completion_tokens?: number | null + total_tokens?: number | null + prompt_tokens_details?: { cached_tokens?: number | null } | null + completion_tokens_details?: { reasoning_tokens?: number | null } | null + /** DeepSeek's legacy cache shape (not nested under prompt_tokens_details). */ + prompt_cache_hit_tokens?: number | null + } | null +} + +interface ChatCompletionToolCallLike { + id: string + function: { name: string; arguments: string } +} + +/** + * Content to attach to a model segment for a single provider iteration. + * All fields are optional — providers populate what the response carries. + */ +export interface ModelSegmentContent { + assistantContent?: string + thinkingContent?: string + toolCalls?: IterationToolCall[] + finishReason?: string + tokens?: BlockTokens + cost?: { input?: number; output?: number; total?: number } + ttft?: number + provider?: string + errorType?: string + errorMessage?: string +} + +/** + * Enriches the most recent `type: 'model'` segment in `timeSegments` with + * content from the model response for that iteration. Writes only the fields + * provided; undefined fields are skipped so repeat calls can layer data. + * + * Call at the point where the response for the latest model segment is in hand + * — typically right after the provider call returns, before tool execution. + */ +export function enrichLastModelSegment( + timeSegments: ProviderTimingSegment[], + content: ModelSegmentContent +): void { + for (let i = timeSegments.length - 1; i >= 0; i--) { + const segment = timeSegments[i] + if (segment.type !== 'model') continue + + if (content.assistantContent !== undefined) { + segment.assistantContent = content.assistantContent + } + if (content.thinkingContent !== undefined) { + segment.thinkingContent = content.thinkingContent + } + if (content.toolCalls !== undefined) { + segment.toolCalls = content.toolCalls + } + if (content.finishReason !== undefined) { + segment.finishReason = content.finishReason + } + if (content.tokens !== undefined) { + segment.tokens = content.tokens + } + if (content.cost !== undefined) { + segment.cost = content.cost + } + if (content.ttft !== undefined) { + segment.ttft = content.ttft + } + if (content.provider !== undefined) { + segment.provider = content.provider + } + if (content.errorType !== undefined) { + segment.errorType = content.errorType + } + if (content.errorMessage !== undefined) { + segment.errorMessage = content.errorMessage + } + return + } +} + +/** + * Parses a tool call's `function.arguments` JSON string into an object, or + * returns the raw string if it is not valid JSON. + */ +function parseToolCallArguments(rawArguments: string): Record | string { + try { + const parsed = JSON.parse(rawArguments) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + return rawArguments + } catch { + return rawArguments + } +} + +/** + * Extracts reasoning/thinking content from a Chat Completions message. Covers + * non-OpenAI extensions emitted by reasoning-capable providers: + * - `reasoning_content`: DeepSeek, xAI, vLLM, Fireworks + * - `reasoning`: Groq, Cerebras, OpenRouter (flat) + * - `reasoning_details[]`: OpenRouter (structured per-block reasoning) + */ +function extractChatCompletionsReasoning( + message: NonNullable['message'] +): string | undefined { + if (!message) return undefined + const msg = message as unknown as { + reasoning_content?: string | null + reasoning?: string | null + reasoning_details?: Array<{ text?: string | null; summary?: string | null } | null> | null + } + + if (typeof msg.reasoning_content === 'string' && msg.reasoning_content.length > 0) { + return msg.reasoning_content + } + if (typeof msg.reasoning === 'string' && msg.reasoning.length > 0) { + return msg.reasoning + } + if (Array.isArray(msg.reasoning_details)) { + const joined = msg.reasoning_details + .map((d) => d?.text ?? d?.summary ?? '') + .filter((s): s is string => typeof s === 'string' && s.length > 0) + .join('\n') + if (joined.length > 0) return joined + } + return undefined +} + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, thinking/reasoning, tool calls, finish + * reason, token usage. Shared by all OpenAI-compat providers. + */ +export function enrichLastModelSegmentFromChatCompletions( + timeSegments: ProviderTimingSegment[], + response: ChatCompletionLike, + toolCallsInResponse: ChatCompletionToolCallLike[] | undefined, + extras?: { + /** Model id used for this call — enables automatic cost calculation. */ + model?: string + /** Provider system identifier (`gen_ai.system`). */ + provider?: string + /** Time-to-first-token in ms (streaming path only). */ + ttft?: number + /** Structured error class when the call failed. */ + errorType?: string + /** Human-readable error message when the call failed. */ + errorMessage?: string + /** Override the automatically derived cost. */ + cost?: { input?: number; output?: number; total?: number } + } +): void { + const choice = response.choices[0] + const assistantText = choice?.message?.content ?? '' + const thinkingText = extractChatCompletionsReasoning(choice?.message) + + const toolCalls: IterationToolCall[] = (toolCallsInResponse ?? []).map((tc) => ({ + id: tc.id, + name: tc.function.name, + arguments: parseToolCallArguments(tc.function.arguments), + })) + + const usage = response.usage + const cacheRead = + usage?.prompt_tokens_details?.cached_tokens ?? usage?.prompt_cache_hit_tokens ?? 0 + const reasoning = usage?.completion_tokens_details?.reasoning_tokens ?? 0 + + const promptTokens = usage?.prompt_tokens ?? undefined + const completionTokens = usage?.completion_tokens ?? undefined + + let derivedCost = extras?.cost + if (!derivedCost && extras?.model && promptTokens != null && completionTokens != null) { + const full = calculateCost(extras.model, promptTokens, completionTokens, cacheRead > 0) + derivedCost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingText, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: choice?.finish_reason ?? undefined, + tokens: usage + ? { + input: promptTokens, + output: completionTokens, + total: usage.total_tokens ?? undefined, + ...(cacheRead > 0 && { cacheRead }), + ...(reasoning > 0 && { reasoning }), + } + : undefined, + cost: derivedCost, + ttft: extras?.ttft, + provider: extras?.provider, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} + +export { parseToolCallArguments } diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 69c36079df7..468d0f8bdaa 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -1,4 +1,4 @@ -import type { StreamingExecution } from '@/executor/types' +import type { ProviderTimingSegment, StreamingExecution } from '@/executor/types' export type ProviderId = | 'openai' @@ -63,13 +63,12 @@ export interface FunctionCallResponse { success?: boolean } -export interface TimeSegment { - type: 'model' | 'tool' - name: string - startTime: number - endTime: number - duration: number -} +/** + * Provider-side alias for the canonical segment type. Providers push these into + * `providerTiming.timeSegments` during execution; the trace pipeline reads them + * verbatim when constructing child spans. + */ +export type TimeSegment = ProviderTimingSegment export interface ProviderResponse { content: string diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 66027c43f96..db25ba45ec0 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -6,6 +6,7 @@ import { env } from '@/lib/core/config/env' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderConfig, @@ -252,7 +253,7 @@ export const vllmProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -329,7 +330,7 @@ export const vllmProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -347,6 +348,14 @@ export const vllmProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'vllm' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -427,6 +436,7 @@ export const vllmProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any @@ -495,7 +505,7 @@ export const vllmProvider: ProviderConfig = { timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -519,6 +529,15 @@ export const vllmProvider: ProviderConfig = { iterationCount++ } + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'vllm' } + ) + } + if (request.stream) { logger.info('Using streaming for final response after tool processing') @@ -662,3 +681,8 @@ export const vllmProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index fdbed7f5c47..309a9fd8f3b 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -5,6 +5,7 @@ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderConfig, @@ -156,7 +157,7 @@ export const xAIProvider: ProviderConfig = { timeSegments: [ { type: 'model', - name: 'Streaming response', + name: request.model, startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -227,7 +228,7 @@ export const xAIProvider: ProviderConfig = { const timeSegments: TimeSegment[] = [ { type: 'model', - name: 'Initial response', + name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, @@ -251,6 +252,14 @@ export const xAIProvider: ProviderConfig = { } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'xai' } + ) + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } @@ -331,6 +340,7 @@ export const xAIProvider: ProviderConfig = { startTime: startTime, endTime: endTime, duration: duration, + toolCallId: toolCall.id, }) let resultContent: any if (result.success && result.output) { @@ -441,7 +451,7 @@ export const xAIProvider: ProviderConfig = { const thisModelTime = nextModelEndTime - nextModelStartTime timeSegments.push({ type: 'model', - name: `Model response (iteration ${iterationCount + 1})`, + name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, @@ -461,6 +471,15 @@ export const xAIProvider: ProviderConfig = { iterationCount++ } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'xai' } + ) + } } catch (error) { logger.error('XAI Provider - Error in tool processing loop:', { error: toError(error).message, @@ -614,3 +633,8 @@ export const xAIProvider: ProviderConfig = { } }, } + +/** + * Enriches the last model segment with per-iteration content from a Chat + * Completions response: assistant text, tool calls, finish reason, token usage. + */ diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index bdd103f16e9..3fbd85bfaee 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -1,3 +1,7 @@ +import type { ProviderTiming, TokenInfo, ToolCall, TraceSpan } from '@/lib/logs/types' + +export type { ProviderTiming, TokenInfo, ToolCall, TraceSpan } + export interface WorkflowData { id: string name: string @@ -6,17 +10,6 @@ export interface WorkflowData { state: any } -export interface ToolCall { - name: string - duration: number // in milliseconds - startTime: string // ISO timestamp - endTime: string // ISO timestamp - status: 'success' | 'error' // Status of the tool call - input?: Record // Input parameters (optional) - output?: Record // Output data (optional) - error?: string // Error message if status is 'error' -} - export interface ToolCallMetadata { toolCalls?: ToolCall[] } @@ -55,52 +48,6 @@ export interface CostMetadata { } } -export interface TokenInfo { - input?: number - output?: number - total?: number - prompt?: number - completion?: number -} - -export interface ProviderTiming { - duration: number - startTime: string - endTime: string - segments: Array<{ - type: string - name?: string - startTime: string | number - endTime: string | number - duration: number - }> -} - -export interface TraceSpan { - id: string - name: string - type: string - duration: number // in milliseconds - startTime: string - endTime: string - children?: TraceSpan[] - toolCalls?: ToolCall[] - status?: 'success' | 'error' - errorHandled?: boolean - tokens?: number | TokenInfo - relativeStartMs?: number // Time in ms from the start of the parent span - blockId?: string // Added to track the original block ID for relationship mapping - input?: Record // Added to store input data for this span - output?: Record // Added to store output data for this span - model?: string - cost?: { - input?: number - output?: number - total?: number - } - providerTiming?: ProviderTiming -} - export interface WorkflowLog { id: string workflowId: string | null diff --git a/apps/sim/stores/logs/store.ts b/apps/sim/stores/logs/store.ts index f9e0361e2c8..0360e7e7bb4 100644 --- a/apps/sim/stores/logs/store.ts +++ b/apps/sim/stores/logs/store.ts @@ -26,6 +26,11 @@ export const useLogDetailsUIStore = create()( { name: 'log-details-ui-state', partialize: (state) => ({ panelWidth: state.panelWidth }), + onRehydrateStorage: () => (state) => { + if (state) { + state.panelWidth = clampPanelWidth(state.panelWidth) + } + }, } ) ) diff --git a/apps/sim/stores/logs/utils.ts b/apps/sim/stores/logs/utils.ts index 4b5d043d1d3..778320066c0 100644 --- a/apps/sim/stores/logs/utils.ts +++ b/apps/sim/stores/logs/utils.ts @@ -1,8 +1,8 @@ /** * Width constraints for the log details panel. */ -export const MIN_LOG_DETAILS_WIDTH = 400 -export const DEFAULT_LOG_DETAILS_WIDTH = 400 +export const MIN_LOG_DETAILS_WIDTH = 600 +export const DEFAULT_LOG_DETAILS_WIDTH = 600 export const MAX_LOG_DETAILS_WIDTH_RATIO = 0.65 /**