feat(logs): trace span tree rewrite with resizable split, provider icons, and execution improvements (#4292)

* improvement(trace-spans): rewrite trace span pipeline with per-iteration enrichment

Unify tool calls under span.children, capture dual-clock timing, and
surface per-iteration model content (assistant text, thinking, tool
calls, finish reason, tokens, cost, ttft, provider, errors) across all
12 LLM providers. UI renders the new fields on model child spans; old
logs degrade gracefully since every field is optional.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* improvement(logs): add Trace tab with two-pane tree+detail view

- Wrap log-details drawer in Overview | Trace tabs; Overview unchanged
- New TraceView with hierarchical tree on the left and detail pane on the right
- Keyboard nav, span filter, expand/collapse all
- Bump min drawer width 400->600 and clamp persisted widths on rehydrate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(logs): retry failed runs + show workflow input in detail

Brings PR #4181 inline: persists workflowInput on successful runs,
adds useRetryExecution mutation (streaming read-one-chunk-and-cancel),
Retry entrypoints in the row context menu and the detail sidebar, and
extractRetryInput with fallback to starter block state for older logs.
Also surfaces the captured input in a new "Workflow Input" section
above Workflow Output in the detail Overview tab, guarded so older
logs without the field don't render an empty block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ui): use inverted popover scheme for usage-control popovers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(logs): trace view chevron padding, section state leak, and tab-scoped keyboard nav

- Pad tree rows from panel edge so the root chevron isn't visually clipped.
- Key DetailCodeSection by label so collapse state belongs to the section
  purpose, preventing isOpen from leaking across span changes when positional
  slots happened to align.
- Ignore log-to-log arrow-key nav while the Trace tab is active so TraceView
  owns span navigation; filter inputs keep native caret movement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(logs): align trace tree rows to 14px content grid

Chevron at depth 0 and the timeline bars now sit on the same 14px left/right
grid as the trace view's header strip and the rest of the log details panel,
removing the stagger where bars extended further left than chevrons and the
chevron appeared cramped against the panel edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(logs): restore scroll in log-details panel

Overview tab's scroll container (SModalTabsContent) was wrapped around a
non-overflow inner div that held the scrollAreaRef, so the scroll-reset on
log change targeted a non-scrolling element. Collapse the wrapper into the
Tabs.Content element itself and move the ref there. Add min-h-0 to the
Trace detail pane wrapper so its scrolling child can shrink inside the
horizontal-flex row.

* fix(logs): hide inactive Overview tab panel

Tailwind's `.flex` utility overrides the UA `[hidden]` rule, so applying
`flex` to SModalTabsContent caused the inactive Overview panel to still
participate in the Tabs flex column and push the Trace view down. Keep
SModalTabsContent as a plain overflow container (no `flex` class) with
the scroll ref on it, and restore the inner flex-col wrapper for the
Overview content so it still stacks with gap spacing.

* fix(logs): trace view padding, section cutoff, keyboard visibility

- Tree pane now has top padding so the first row has breathing room
  under the header strip instead of sitting flush against the border.
- DetailCodeSection dropped its wrapper `overflow-hidden`. Per CSS, a
  flex item with `overflow: hidden` resolves `min-height: auto` to `0`,
  so when Input and Output were both expanded the flex algorithm
  shrank each section below its content, cutting off rows. Without the
  clip, sections size to content and the surrounding pane's
  `overflow-y-auto` takes over.
- Selected span row now scrolls into view on selection change, so
  arrow-key navigation always keeps the active row visible in the
  tree pane.

* fix(logs): inline Workflow State row and lift search dropdown z-index

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(logs): use emcn Button for View Snapshot action

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* minor improvements

* fix(logs): trace view resizable split, bar visibility, provider icons, cleanup

- Resizable tree/detail split in trace view (default 360px, drag to resize)
- Resizable right panel in preview snapshot (280–600px)
- Fix Gantt bar invisibility for late-run spans (clamp offsetPct to 100-MIN_BAR_PCT)
- Propagate model+provider to child model spans in span-factory for correct icons
- Fix icon contrast on light provider backgrounds (luminance-based color class)
- Replace custom status badges with emcn Badge component
- Lighten jump-to-error button to ghost variant
- Remove double X button in modal snapshot (showBlockCloseButton prop)
- Fix emcn subpath imports → barrel in trace-view, log-details, execution-snapshot
- Fix hover: → hover-hover: on resize handles
- Add body style cleanup on resize unmount
- Fix React Query key factory naming (stats/stat convention)
- Remove unnecessary useCallback/useMemo in preview and execution-snapshot

* fix(ui): scroll guard, credentials UX, design token fixes, input padding

- logs: only scroll-into-view on keyboard nav, not on click selection
- resource: stable scrollbar gutter, wider first column
- credentials: toast success/error feedback, remove useMemo for personalEnvData,
  allow editing conflict rows, fix disabled state visibility, use --text-error token
- integrations: use --text-error token for error state
- input: increase right padding (px-2 → pl-2 pr-3)

* chore(skills): add /ship command to claude, cursor, and agents

* fix(input): add scroll-pr-1 to keep caret visible when text overflows

* fix(logs): address PR review — iteration name guard, cost race, mothership retry

* improvement(logs): cleanup pass — remove anti-patterns, fix design tokens, simplify state

* fix(trace-spans): extend final model segment by position not by stale constant name

* fix(modal): restore sidebar-width padding on non-workflow pages

* fix(secrets): eliminate slow save by parallelizing DB ops and fixing stuck button

Sequential per-variable, per-workspace DB round-trips in syncPersonalEnvCredentialsForUser caused O(W×K) latency (800–1600ms for 10 workspaces). Replaced with parallel workspace processing and batched upserts. Also parallelized secret decryption in the GET handler.

On the client, removed the changeToken bug that left the Save button permanently disabled after a failed save, split the shared hasSavedRef into two independent flags to eliminate ordering races, and moved ref updates to after mutation success so optimistic state can never get stuck.

* updated sap block

* fix(sap): remove slash from S4HANA name, set white bgColor, regenerate docs

* fix(logs): prevent log-row arrow navigation when trace tab is active

* fix(logs): aggregate cost onto workflow root span; stabilize onActiveTabChange callback

* improvement(logs): fix Gantt time bounds to walk full span tree; cleanup effects, memos, callbacks, React Query mutations

* fix(logs): reset detail panel tab to overview on log switch

* chore(logs): remove extraneous comments

* fix(logs): restore useEffect for async setActiveWorkflow and useMemo on rowProps

- resource-content.tsx: revert render-time setActiveWorkflow call to useEffect; the store action is async and performs network ops, calling it during render violates React purity
- logs-list.tsx: restore useMemo on rowProps to prevent virtualized list rows from re-rendering on every parent render

* fix(queries): forward AbortSignal in mothership-admin query functions

All queryFn callbacks must forward signal for request cancellation per project React Query standards.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-04-28 17:34:49 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ed206ed7d8
commit 9a4cd95182
88 changed files with 5175 additions and 2208 deletions
+82
View File
@@ -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
+82
View File
@@ -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
+77
View File
@@ -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
+11 -11
View File
@@ -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"
<BlockInfoCard
type="sap_s4hana"
color="#0A6ED1"
color="#FFFFFF"
/>
{/* 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.
+1 -5
View File
@@ -98,11 +98,7 @@ function SignupFormContent({ githubAvailable, googleAvailable, isProduction }: S
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()
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)
@@ -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 (
<Modal open={open} onOpenChange={handleOpenChange}>
@@ -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<string | undefined>()
useEffect(() => {
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
}, [])
const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
function updateField<TField extends keyof ContactFormState>(
field: TField,
@@ -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": [
+15 -10
View File
@@ -120,17 +120,22 @@ export const GET = withRouteHandler(async (request: Request) => {
}
const encryptedVariables = result[0].variables as Record<string, string>
const decryptedVariables: Record<string, EnvironmentVariable> = {}
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) {
@@ -313,7 +313,10 @@ export const ResourceTable = memo(function ResourceTable({
</thead>
</table>
</div>
<div className='min-h-0 flex-1 overflow-auto' onScroll={handleBodyScroll}>
<div
className='min-h-0 flex-1 overflow-auto [scrollbar-gutter:stable]'
onScroll={handleBodyScroll}
>
<table className='w-full table-fixed text-small'>
<ResourceColGroup columns={columns} hasCheckbox={hasCheckbox} />
<tbody>
@@ -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) }
}
/>
@@ -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 ? (
<button
type='button'
onClick={() => setExpanded((prev) => !prev)}
onClick={() => setCollapsedByUser((prev) => !prev)}
aria-expanded={expanded}
className='flex items-center gap-2'
>
@@ -527,12 +523,12 @@ export function WorkspaceResourceDisplay({
}
}, [data.id, data.type, files, knowledgeBases, tables, workflows])
const context = useMemo(() => toChatMessageContext(data, resource.title), [data, resource.title])
const context = toChatMessageContext(data, resource.title)
const workflowColor = useMemo(() => {
if (data.type !== 'workflow') return null
return workflows.find((workflow) => workflow.id === data.id)?.color ?? null
}, [data.id, data.type, workflows])
const workflowColor =
data.type === 'workflow'
? (workflows.find((workflow) => workflow.id === data.id)?.color ?? null)
: null
const mentionContent = (
<>
@@ -249,8 +249,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
const { usageExceeded } = useUsageLimits()
useEffect(() => {
setActiveWorkflow(workflowId)
}, [setActiveWorkflow, workflowId])
void setActiveWorkflow(workflowId)
}, [workflowId, setActiveWorkflow])
const isRunButtonDisabled =
!isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading
@@ -239,16 +239,16 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
valueRef.current = value
const sttPrefixRef = useRef('')
const handleTranscript = useCallback((text: string) => {
function handleTranscript(text: string) {
const prefix = sttPrefixRef.current
const newVal = prefix ? `${prefix} ${text}` : text
setValue(newVal)
valueRef.current = newVal
}, [])
}
const handleUsageLimitExceeded = useCallback(() => {
function handleUsageLimitExceeded() {
navigateToSettings({ section: 'subscription' })
}, [navigateToSettings])
}
const {
isListening,
@@ -588,7 +588,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
[handleSubmit, mentionTokensWithContext, value, textareaRef]
)
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
function handleInputChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
@@ -608,7 +608,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
}
setValue(newValue)
}, [])
}
const handleSelectAdjust = useCallback(() => {
const textarea = textareaRef.current
@@ -125,11 +125,11 @@ export function Home({ chatId }: HomeProps = {}) {
setIsResourceCollapsed(true)
}, [clearWidth])
const handleResourceEvent = useCallback(() => {
function handleResourceEvent() {
if (isResourceCollapsedRef.current) {
setIsResourceCollapsed(false)
}
}, [])
}
const {
messages,
@@ -201,34 +201,35 @@ export function Home({ chatId }: HomeProps = {}) {
}
}, [resources, collapseResource])
const handleStopGeneration = useCallback(() => {
function handleStopGeneration() {
captureEvent(posthogRef.current, 'task_generation_aborted', {
workspace_id: workspaceId,
view: 'mothership',
})
void stopGeneration().catch(() => {})
}, [stopGeneration, workspaceId])
}
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
function handleSubmit(
text: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[]
) {
const trimmed = text.trim()
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
captureEvent(posthogRef.current, 'task_message_sent', {
workspace_id: workspaceId,
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
has_contexts: !!(contexts && contexts.length > 0),
is_new_task: !chatId,
})
captureEvent(posthogRef.current, 'task_message_sent', {
workspace_id: workspaceId,
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
has_contexts: !!(contexts && contexts.length > 0),
is_new_task: !chatId,
})
if (initialViewInputRef.current) {
setIsInputEntering(true)
}
if (initialViewInputRef.current) {
setIsInputEntering(true)
}
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
},
[sendMessage, workspaceId, chatId]
)
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
}
useEffect(() => {
const handler = (e: Event) => {
@@ -239,55 +240,45 @@ export function Home({ chatId }: HomeProps = {}) {
return () => window.removeEventListener('mothership-send-message', handler)
}, [sendMessage])
const resolveResourceFromContext = useCallback(
(context: ChatContext): { type: MothershipResourceType; id: string } | null => {
switch (context.kind) {
case 'workflow':
case 'current_workflow':
return context.workflowId ? { type: 'workflow', id: context.workflowId } : null
case 'knowledge':
return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null
case 'table':
return context.tableId ? { type: 'table', id: context.tableId } : null
case 'file':
return context.fileId ? { type: 'file', id: context.fileId } : null
default:
return null
}
},
[]
)
function resolveResourceFromContext(
context: ChatContext
): { type: MothershipResourceType; id: string } | null {
switch (context.kind) {
case 'workflow':
case 'current_workflow':
return context.workflowId ? { type: 'workflow', id: context.workflowId } : null
case 'knowledge':
return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null
case 'table':
return context.tableId ? { type: 'table', id: context.tableId } : null
case 'file':
return context.fileId ? { type: 'file', id: context.fileId } : null
default:
return null
}
}
const handleContextAdd = useCallback(
(context: ChatContext) => {
const resolved = resolveResourceFromContext(context)
if (resolved) {
addResource({ ...resolved, title: context.label })
handleResourceEvent()
}
},
[resolveResourceFromContext, addResource, handleResourceEvent]
)
const handleInitialContextRemove = useCallback(
(context: ChatContext) => {
const resolved = resolveResourceFromContext(context)
if (!resolved) return
removeResource(resolved.type, resolved.id)
},
[resolveResourceFromContext, removeResource]
)
const handleWorkspaceResourceSelect = useCallback(
(resource: MothershipResource) => {
const wasAdded = addResource(resource)
if (!wasAdded) {
setActiveResourceId(resource.id)
}
function handleContextAdd(context: ChatContext) {
const resolved = resolveResourceFromContext(context)
if (resolved) {
addResource({ ...resolved, title: context.label })
handleResourceEvent()
},
[addResource, handleResourceEvent, setActiveResourceId]
)
}
}
function handleInitialContextRemove(context: ChatContext) {
const resolved = resolveResourceFromContext(context)
if (!resolved) return
removeResource(resolved.type, resolved.id)
}
function handleWorkspaceResourceSelect(resource: MothershipResource) {
const wasAdded = addResource(resource)
if (!wasAdded) {
setActiveResourceId(resource.id)
}
handleResourceEvent()
}
const hasMessages = messages.length > 0
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -39,12 +39,15 @@ export function RenameDocumentModal({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
// Reset form fields when the modal opens (open transitions false → true).
const prevOpenRef = useRef(open)
if (prevOpenRef.current !== open) {
prevOpenRef.current = open
if (open) {
setName(initialName)
setError(null)
}
}, [open, initialName])
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -1,4 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
@@ -264,17 +265,16 @@ function LineChartComponent({
const isHovered = hoverSeriesId === s.id
const dimmed = activeSeriesId ? !isActive : false
return (
<button
<Button
key={`legend-${s.id}`}
type='button'
variant='ghost'
aria-pressed={activeSeriesId === s.id}
aria-label={`Toggle ${s.label}`}
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-micro'
className='inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-transparent px-1.5 py-0.5 text-micro'
style={{
color: resolvedColors[s.id || ''] || s.color,
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
border: '1px solid hsl(var(--border))',
background: 'transparent',
}}
onMouseEnter={() => setHoverSeriesId(s.id || null)}
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
@@ -294,7 +294,7 @@ function LineChartComponent({
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
/>
<span className='text-[var(--text-muted)]'>{s.label}</span>
</button>
</Button>
)
})}
</div>
@@ -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'
@@ -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<HTMLDivElement>(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}
/>
)
}
@@ -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<string>
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 (
<div className='relative flex min-w-0 flex-col gap-1.5 overflow-hidden'>
@@ -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)
})
}
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,
}))
// Add regular children
if (span.children && span.children.length > 0) {
children.push(...span.children)
}
kids.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
// Sort by start time
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
}, [span, spanId, spanStartTime])
// 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) && (
<div className='flex min-w-0 flex-col gap-1.5 overflow-hidden py-0.5'>
{hasInput && (
<InputOutputSection
@@ -670,13 +752,125 @@ const TraceSpanNode = memo(function TraceSpanNode({
onToggle={onToggleSection}
/>
)}
{hasThinking && (
<>
{(hasInput || hasOutput) && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
<InputOutputSection
label='Thinking'
data={span.thinking}
isError={false}
spanId={spanId}
sectionType='thinking'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
</>
)}
{hasModelToolCalls && (
<>
{(hasInput || hasOutput || hasThinking) && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
<InputOutputSection
label='Tool calls'
data={span.modelToolCalls}
isError={false}
spanId={spanId}
sectionType='modelToolCalls'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
</>
)}
{hasErrorMessage && (
<>
{(hasInput || hasOutput || hasThinking || hasModelToolCalls) && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
<InputOutputSection
label='Error'
data={span.errorMessage}
isError
spanId={spanId}
sectionType='errorMessage'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
</>
)}
{hasErrorType && (
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Error type</span>
<span className='min-w-0 truncate text-right text-[var(--text-error)]'>
{span.errorType}
</span>
</div>
)}
{hasFinishReason && (
<div className='flex items-center justify-between font-medium text-caption'>
<span className='text-[var(--text-tertiary)]'>Finish reason</span>
<span className='text-[var(--text-secondary)]'>{span.finishReason}</span>
</div>
)}
{hasProvider && (
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Provider</span>
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
{span.provider}
</span>
</div>
)}
{hasTtft && (
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>TTFT</span>
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
{ttftSummary}
</span>
</div>
)}
{hasTokens && (
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Tokens</span>
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
{tokensSummary}
</span>
</div>
)}
{hasTps && (
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Throughput</span>
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
{tpsSummary}
</span>
</div>
)}
{hasCost && (
<div className='flex items-center justify-between gap-2 font-medium text-caption'>
<span className='flex-shrink-0 text-[var(--text-tertiary)]'>Cost</span>
<span className='min-w-0 truncate text-right text-[var(--text-secondary)]'>
{costSummary}
</span>
</div>
)}
</div>
)}
{/* Nested Children */}
{hasChildren && (
<div className='flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-2.5'>
{filteredChildren.map((child, index) => (
{displayChildren.map((child, index) => (
<div key={child.id || `${spanId}-child-${index}`} className='pl-1.5'>
<TraceSpanNode
span={child}
@@ -0,0 +1 @@
export { TraceView } from './trace-view'
@@ -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 (
<div className='relative flex min-w-0 flex-col overflow-hidden'>
@@ -209,7 +210,11 @@ export const WorkflowOutputSection = memo(
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<DropdownMenu open={isContextMenuOpen} onOpenChange={closeContextMenu} modal={false}>
<DropdownMenu
open={isContextMenuOpen}
onOpenChange={() => setIsContextMenuOpen(false)}
modal={false}
>
<DropdownMenuTrigger asChild>
<div
style={{
@@ -264,6 +269,12 @@ interface LogDetailsProps {
hasNext?: boolean
/** Whether there is a previous log available */
hasPrev?: boolean
/** Callback to retry a failed execution */
onRetryExecution?: () => 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<LogDetailsTab>('overview')
const [prevLogId, setPrevLogId] = useState(log?.id)
const [copiedRunId, setCopiedRunId] = useState(false)
if (prevLogId !== log?.id) {
setPrevLogId(log?.id)
setActiveTab('overview')
}
const copiedRunIdTimerRef = useRef<number | null>(null)
const scrollAreaRef = useRef<HTMLDivElement>(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<LogDetailsTab>(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<string, unknown>
}, [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<string, unknown>
}
return { input: raw } as Record<string, unknown>
}, [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
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
e.preventDefault()
onNavigateNext()
}
// 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 === '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 && (
<div
className='absolute top-0 bottom-0 z-[60] w-[8px] cursor-ew-resize'
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
style={{ right: `calc(${effectiveWidth} - 4px)` }}
onMouseDown={handleMouseDown}
role='separator'
@@ -353,9 +413,10 @@ export const LogDetails = memo(function LogDetails({
)}
<div
className={`absolute top-[0px] right-0 bottom-0 z-50 transform overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out ${
className={cn(
'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
)}
style={{ width: effectiveWidth }}
aria-label='Log details sidebar'
>
@@ -365,6 +426,24 @@ export const LogDetails = memo(function LogDetails({
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[var(--text-primary)] text-sm'>Log Details</h2>
<div className='flex items-center gap-[1px]'>
{log?.status === 'failed' &&
(log?.workflow?.id || log?.workflowId) &&
log?.trigger !== 'mothership' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='!p-1'
onClick={() => onRetryExecution?.()}
disabled={isRetryPending}
aria-label='Retry execution'
>
<Redo className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Retry</Tooltip.Content>
</Tooltip.Root>
)}
<Button
variant='ghost'
className='!p-1'
@@ -389,262 +468,280 @@ export const LogDetails = memo(function LogDetails({
</div>
</div>
{/* Content - Scrollable */}
<div className='mt-5 h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='flex flex-col gap-2.5 pb-4'>
{/* Timestamp & Workflow Row */}
<div className='flex min-w-0 items-center gap-4 px-[1px]'>
{/* Timestamp Card */}
<div className='flex w-[140px] flex-shrink-0 flex-col gap-2'>
<div className='font-medium text-[var(--text-tertiary)] text-caption'>
Timestamp
</div>
<div className='flex items-center gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-sm'>
{formattedTimestamp?.compactDate || 'N/A'}
{/* Tabs */}
<SModalTabs
value={resolvedTab}
onValueChange={(v) => {
const tab = v as LogDetailsTab
setActiveTab(tab)
onActiveTabChange?.(tab)
}}
className='mt-4 flex min-h-0 flex-1 flex-col'
>
<SModalTabsList
activeValue={resolvedTab}
className='!px-0 border-[var(--border)] border-b'
>
<SModalTabsTrigger value='overview'>Overview</SModalTabsTrigger>
{showTraceTab && <SModalTabsTrigger value='trace'>Trace</SModalTabsTrigger>}
</SModalTabsList>
{/* Overview Tab */}
<SModalTabsContent
ref={scrollAreaRef}
value='overview'
className='mt-4 min-h-0 flex-1 overflow-y-auto'
>
<div className='flex flex-col gap-2.5 pb-4'>
{/* Timestamp + Workflow header */}
<div className='grid grid-cols-2 gap-x-3 pb-0.5'>
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Timestamp
</span>
<span className='font-medium text-[var(--text-secondary)] text-sm'>
{formattedTimestamp?.compactTime || 'N/A'}
<span className='font-medium text-[var(--text-secondary)] text-sm tabular-nums'>
{formattedTimestamp
? `${formattedTimestamp.compactDate} ${formattedTimestamp.compactTime}`
: '—'}
</span>
</div>
</div>
{/* Workflow Card */}
<div className='flex w-0 min-w-0 flex-1 flex-col gap-2'>
<div className='font-medium text-[var(--text-tertiary)] text-caption'>
{log.trigger === 'mothership' ? 'Job' : 'Workflow'}
</div>
<div className='flex min-w-0 items-center gap-2'>
{(() => {
const c =
log.trigger === 'mothership'
? '#ec4899'
: log.workflow?.color ||
(!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined)
return (
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: c,
borderColor: c ? workflowBorderColor(c) : undefined,
backgroundClip: 'padding-box',
}}
/>
)
})()}
<span className='min-w-0 flex-1 truncate font-medium text-[var(--text-secondary)] text-sm'>
{log.trigger === 'mothership'
? log.jobTitle || 'Untitled Job'
: log.workflow?.name ||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
{log.trigger === 'mothership' ? 'Job' : 'Workflow'}
</span>
</div>
</div>
</div>
{/* Run ID */}
{log.executionId && (
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Run ID
</span>
<span className='truncate font-medium text-[var(--text-secondary)] text-sm'>
{log.executionId}
</span>
</div>
)}
{/* Details Section */}
<div className='-my-1 flex min-w-0 flex-col overflow-hidden'>
{/* Level */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-2'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Level
</span>
<StatusBadge status={logStatus} />
</div>
{/* Trigger */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-2'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Trigger
</span>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
<span className='font-medium text-[var(--text-secondary)] text-caption'>
—
</span>
)}
</div>
{/* Duration */}
<div
className={`flex h-[48px] items-center justify-between border-b p-2 ${log.deploymentVersion ? 'border-[var(--border)]' : 'border-transparent'}`}
>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Duration
</span>
<span className='font-medium text-[var(--text-secondary)] text-small'>
{formatDuration(log.duration, { precision: 2 }) || '—'}
</span>
</div>
{/* Version */}
{log.deploymentVersion && (
<div className='flex h-[48px] items-center gap-2 p-2'>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
Version
</span>
<div className='flex w-0 flex-1 justify-end'>
<span className='max-w-full truncate rounded-md bg-[var(--badge-success-bg)] px-[9px] py-0.5 font-medium text-[var(--badge-success-text)] text-caption'>
{log.deploymentVersionName || `v${log.deploymentVersion}`}
<div className='flex min-w-0 items-center gap-1.5'>
{(() => {
const c =
log.trigger === 'mothership'
? '#ec4899'
: log.workflow?.color ||
(!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined)
return (
<div
className='h-[8px] w-[8px] flex-shrink-0 rounded-[2px] border-[1.5px]'
style={{
backgroundColor: c,
borderColor: c ? workflowBorderColor(c) : undefined,
backgroundClip: 'padding-box',
}}
/>
)
})()}
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
{log.trigger === 'mothership'
? log.jobTitle || 'Untitled Job'
: log.workflow?.name ||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
</span>
</div>
</div>
</div>
{/* Details Section */}
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
{/* Run ID — click to copy */}
{log.executionId && (
<div
className='flex h-10 min-w-0 cursor-pointer items-center justify-between gap-4 px-3 transition-colors hover-hover:bg-[var(--surface-2)]'
onClick={() => {
navigator.clipboard.writeText(log.executionId!)
if (copiedRunIdTimerRef.current) clearTimeout(copiedRunIdTimerRef.current)
setCopiedRunId(true)
copiedRunIdTimerRef.current = window.setTimeout(
() => setCopiedRunId(false),
1500
)
}}
>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
Run ID
</span>
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{copiedRunId ? 'Copied!' : log.executionId}
</span>
</div>
)}
{/* Level */}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Level
</span>
<StatusBadge status={logStatus} />
</div>
{/* Trigger */}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Trigger
</span>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
<span className='font-medium text-[var(--text-secondary)] text-caption'>
—
</span>
)}
</div>
{/* Duration */}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Duration
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{formatDuration(log.duration, { precision: 2 }) || '—'}
</span>
</div>
{/* Version */}
{log.deploymentVersion && (
<div className='flex h-10 items-center gap-2 px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
Version
</span>
<div className='flex w-0 flex-1 justify-end'>
<span className='max-w-full truncate rounded-md bg-[var(--badge-success-bg)] px-[9px] py-0.5 font-medium text-[var(--badge-success-text)] text-caption'>
{log.deploymentVersionName || `v${log.deploymentVersion}`}
</span>
</div>
</div>
)}
{/* Snapshot */}
{showWorkflowState && (
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Snapshot
</span>
<Button
variant='default'
size='sm'
className='gap-1'
onClick={() => setIsExecutionSnapshotOpen(true)}
>
<Eye className='h-3 w-3' />
View Snapshot
</Button>
</div>
)}
</div>
{/* Workflow Input */}
{isWorkflowExecutionLog && workflowInput && !permissionConfig.hideTraceSpans && (
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Workflow Input
</span>
<WorkflowOutputSection output={workflowInput} />
</div>
)}
{/* Workflow Output */}
{isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
<span
className={cn(
'font-medium text-caption',
workflowOutput.error
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)]'
)}
>
Workflow Output
</span>
<WorkflowOutputSection output={workflowOutput} />
</div>
)}
{/* Files */}
{log.files && log.files.length > 0 && (
<FileCards files={log.files} isExecutionFile />
)}
{/* Cost Breakdown */}
{hasCostInfo && (
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Base Run
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{formatCost(BASE_EXECUTION_CHARGE)}
</span>
</div>
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Model Input
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{formatCost(log.cost?.input || 0)}
</span>
</div>
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Model Output
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{formatCost(log.cost?.output || 0)}
</span>
</div>
{(() => {
const models = (log.cost as Record<string, unknown>)?.models as
| Record<string, { toolCost?: number }>
| undefined
const totalToolCost = models
? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0)
: 0
return totalToolCost > 0 ? (
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Tool Usage
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{formatCost(totalToolCost)}
</span>
</div>
) : null
})()}
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
Total
</span>
<span className='font-semibold text-[var(--text-primary)] text-caption tabular-nums'>
{formatCost(log.cost?.total || 0)}
</span>
</div>
<div className='flex h-10 items-center justify-between px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Tokens
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in ·{' '}
{log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out
</span>
</div>
<div className='px-3 py-2'>
<p className='font-medium text-[var(--text-tertiary)] text-xs'>
Total includes a {formatCost(BASE_EXECUTION_CHARGE)} base charge plus
model and tool usage.
</p>
</div>
</div>
)}
</div>
</SModalTabsContent>
{/* Workflow State */}
{isWorkflowExecutionLog &&
log.executionId &&
log.trigger !== 'mothership' &&
!permissionConfig.hideTraceSpans && (
<div className='-mt-2 flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Workflow State
</span>
<Button
variant='active'
onClick={() => setIsExecutionSnapshotOpen(true)}
className='flex w-full items-center justify-between px-2.5 py-1.5'
>
<span className='font-medium text-caption'>View Snapshot</span>
<Eye className='h-[14px] w-[14px]' />
</Button>
</div>
)}
{/* Workflow Output */}
{isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
<div className='mt-1 flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
<span
className={cn(
'font-medium text-caption',
workflowOutput.error
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)]'
)}
>
Workflow Output
</span>
<WorkflowOutputSection output={workflowOutput} />
</div>
)}
{/* Workflow Execution - Trace Spans */}
{isWorkflowExecutionLog &&
log.executionData?.traceSpans &&
!permissionConfig.hideTraceSpans && (
<div className='mt-1 flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Trace Span
</span>
<TraceSpans traceSpans={log.executionData.traceSpans} />
</div>
)}
{/* Files */}
{log.files && log.files.length > 0 && (
<FileCards files={log.files} isExecutionFile />
)}
{/* Cost Breakdown */}
{hasCostInfo && (
<div className='flex flex-col gap-2'>
<span className='px-[1px] font-medium text-[var(--text-tertiary)] text-caption'>
Cost Breakdown
</span>
<div className='flex flex-col gap-1 rounded-md border border-[var(--border)]'>
<div className='flex flex-col gap-2.5 rounded-md p-2.5'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Base Run:
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
{formatCost(BASE_EXECUTION_CHARGE)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Model Input:
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
{formatCost(log.cost?.input || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Model Output:
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
{formatCost(log.cost?.output || 0)}
</span>
</div>
{(() => {
const models = (log.cost as Record<string, unknown>)?.models as
| Record<string, { toolCost?: number }>
| undefined
const totalToolCost = models
? Object.values(models).reduce((sum, m) => sum + (m?.toolCost || 0), 0)
: 0
return totalToolCost > 0 ? (
<div className='flex items-center justify-between'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Tool Usage:
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
{formatCost(totalToolCost)}
</span>
</div>
) : null
})()}
</div>
<div className='border-[var(--border)] border-t' />
<div className='flex flex-col gap-2.5 rounded-md p-2.5'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Total:
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
{formatCost(log.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Tokens:
</span>
<span className='font-medium text-[var(--text-secondary)] text-caption'>
{log.cost?.tokens?.input || log.cost?.tokens?.prompt || 0} in /{' '}
{log.cost?.tokens?.output || log.cost?.tokens?.completion || 0} out
</span>
</div>
</div>
</div>
<div className='flex items-center justify-center rounded-md bg-[var(--surface-2)] p-2 text-center'>
<p className='font-medium text-[var(--text-subtle)] text-xs'>
Total cost includes a base run charge of {formatCost(BASE_EXECUTION_CHARGE)}{' '}
plus any model and tool usage costs.
</p>
</div>
</div>
)}
</div>
</div>
{/* Trace Tab */}
{showTraceTab && log.executionData?.traceSpans && (
<SModalTabsContent
value='trace'
className='mt-3 min-h-0 flex-1 overflow-hidden focus-visible:outline-none'
>
<TraceView traceSpans={log.executionData.traceSpans} />
</SModalTabsContent>
)}
</SModalTabs>
</div>
)}
@@ -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 (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -73,6 +84,15 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isRetryable && (
<>
<DropdownMenuItem onSelect={onRetryExecution} disabled={isRetryPending}>
<Redo />
{isRetryPending ? 'Retrying...' : 'Retry'}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{isCancellable && (
<>
<DropdownMenuItem onSelect={onCancelExecution}>
@@ -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
@@ -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) && (
<button
<Button
type='button'
className='mr-2 ml-1.5 flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover-hover:text-[var(--text-secondary)]'
variant='ghost'
className='mr-2 ml-1.5 h-[14px] w-[14px] flex-shrink-0 p-0 text-[var(--text-subtle)] hover-hover:text-[var(--text-secondary)]'
onClick={clearAll}
>
<X className='h-[14px] w-[14px]' />
</button>
</Button>
)}
</div>
</DropdownMenuPrimitive.Trigger>
@@ -248,7 +249,7 @@ export function AutocompleteSearch({
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={dropdownRef}
className='z-50 rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
className='z-[70] rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
style={{ width: dropdownWidth }}
align='start'
sideOffset={4}
@@ -259,11 +260,12 @@ export function AutocompleteSearch({
<div className='py-1'>
{/* Show all results (no header) */}
{suggestions[0]?.category === 'show-all' && (
<button
<Button
key={suggestions[0].id}
variant='ghost'
data-index={0}
className={cn(
'w-full rounded-md px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'hover-hover:bg-[var(--surface-5)]',
highlightedIndex === 0 && 'bg-[var(--surface-5)]'
)}
@@ -274,7 +276,7 @@ export function AutocompleteSearch({
}}
>
<div className='text-small'>{suggestions[0].label}</div>
</button>
</Button>
)}
{sections.map((section) => (
@@ -289,11 +291,12 @@ export function AutocompleteSearch({
const isHighlighted = index === highlightedIndex
return (
<button
<Button
key={suggestion.id}
variant='ghost'
data-index={index}
className={cn(
'w-full rounded-md px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'hover-hover:bg-[var(--surface-5)]',
isHighlighted && 'bg-[var(--surface-5)]'
)}
@@ -316,7 +319,7 @@ export function AutocompleteSearch({
</div>
)}
</div>
</button>
</Button>
)
})}
</div>
@@ -332,11 +335,12 @@ export function AutocompleteSearch({
)}
{suggestions.map((suggestion, index) => (
<button
<Button
key={suggestion.id}
variant='ghost'
data-index={index}
className={cn(
'w-full rounded-md px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
'hover-hover:bg-[var(--surface-5)]',
index === highlightedIndex && 'bg-[var(--surface-5)]'
)}
@@ -354,7 +358,7 @@ export function AutocompleteSearch({
</div>
)}
</div>
</button>
</Button>
))}
</div>
)}
@@ -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 (
<div className='flex flex-col gap-[19px]'>
@@ -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<WorkflowLog[]>([])
const selectedLogIndexRef = useRef(-1)
const selectedLogIdRef = useRef<string | null>(null)
const shouldScrollIntoViewRef = useRef(false)
const logsRefetchRef = useRef<() => void>(() => {})
const activeLogRefetchRef = useRef<() => void>(() => {})
const activeLogTabRef = useRef<string>('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<WorkflowLog | null>(null)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [previewLogId, setPreviewLogId] = useState<string | null>(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(
() => (
<LogDetails
log={selectedLog}
isOpen={effectiveSidebarOpen}
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
hasNext={selectedLogIndex < sortedLogs.length - 1}
hasPrev={selectedLogIndex > 0}
/>
),
[
selectedLog,
effectiveSidebarOpen,
handleCloseSidebar,
handleNavigateNext,
handleNavigatePrev,
selectedLogIndex,
sortedLogs.length,
]
const sidebarOverlay = (
<LogDetails
log={selectedLog}
isOpen={effectiveSidebarOpen}
onClose={handleCloseSidebar}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
hasNext={selectedLogIndex < sortedLogs.length - 1}
hasPrev={selectedLogIndex > 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<string | undefined>(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 && (
<ExecutionSnapshot
executionId={activeLogQuery.data.executionId}
traceSpans={activeLogQuery.data.executionData?.traceSpans}
isModal
isOpen={isPreviewOpen}
isOpen={previewLogId !== null}
onClose={handleClosePreview}
/>
)}
@@ -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 (
<button
<Button
type='button'
variant='ghost'
data-index={index}
className={cn(
'w-full rounded-md px-3 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]',
'h-auto w-full justify-start rounded-md px-3 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]',
highlighted && 'bg-[var(--surface-5)]'
)}
onMouseEnter={() => onHover(index)}
@@ -1560,7 +1587,7 @@ function SuggestionButton({
onSelect(suggestion)
}}
>
<div className='flex items-center justify-between gap-3'>
<div className='flex w-full items-center justify-between gap-3'>
<div className='min-w-0 flex-1 truncate text-small'>{suggestion.label}</div>
{showCategory && suggestion.value !== suggestion.label && (
<div className='shrink-0 font-mono text-[var(--text-muted)] text-xs'>
@@ -1573,6 +1600,6 @@ function SuggestionButton({
<div className='shrink-0 text-[var(--text-muted)] text-xs'>{suggestion.value}</div>
)}
</div>
</button>
</Button>
)
}
@@ -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<string, unknown> | 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
}
@@ -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<UIEnvironmentVariable[]>([])
const [newWorkspaceRows, setNewWorkspaceRows] = useState<UIEnvironmentVariable[]>([
@@ -414,8 +413,6 @@ export function CredentialsManager() {
const [workspaceVars, setWorkspaceVars] = useState<Record<string, string>>({})
const [renamingKey, setRenamingKey] = useState<string | null>(null)
const [pendingKeyValue, setPendingKeyValue] = useState<string>('')
const [changeToken, setChangeToken] = useState(0)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
const [prevSelectedCredentialId, setPrevSelectedCredentialId] = useState<
string | null | undefined
@@ -432,7 +429,8 @@ export function CredentialsManager() {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
const hasChangesRef = useRef(false)
const hasSavedRef = useRef(false)
const hasSavedPersonalRef = useRef(false)
const hasSavedWorkspaceRef = useRef(false)
const shouldBlockNavRef = useRef(false)
const pendingNavigationUrlRef = useRef<string | null>(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<unknown>[] = []
setShowUnsavedChanges(false)
const mergedWorkspaceVars = { ...workspaceVars }
for (const row of newWorkspaceRows) {
if (row.key && row.value) {
mergedWorkspaceVars[row.key] = row.value
}
}
const validVariables = envVars
.filter((v) => v.key && v.value)
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
const before = initialWorkspaceVarsRef.current
const after = mergedWorkspaceVars
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
for (const [k, v] of Object.entries(after)) {
if (!(k in before) || before[k] !== v) {
toUpsert[k] = v
}
}
for (const k of Object.keys(before)) {
if (!(k in after)) toDelete.push(k)
}
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
})()
const workspaceChanged =
workspaceId && (Object.keys(toUpsert).length > 0 || toDelete.length > 0)
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 {
setShowUnsavedChanges(false)
hasSavedRef.current = true
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)))
setChangeToken((prev) => prev + 1)
const validVariables = envVars
.filter((v) => v.key && v.value)
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
const before = prevInitialWorkspaceVars
const after = mergedWorkspaceVars
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
for (const [k, v] of Object.entries(after)) {
if (!(k in before) || before[k] !== v) {
toUpsert[k] = v
}
}
for (const k of Object.keys(before)) {
if (!(k in after)) toDelete.push(k)
}
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 })
}
})()
)
}
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 && (
<Button
variant='default'
onClick={() => handleViewDetails(envVar.key, 'env_personal')}
@@ -1267,7 +1269,7 @@ export function CredentialsManager() {
</div>
{detailsError && (
<div className='rounded-lg border border-[color-mix(in_srgb,var(--status-red)_40%,transparent)] bg-[color-mix(in_srgb,var(--status-red)_10%,transparent)] px-2.5 py-2 text-[var(--status-red)] text-small'>
<div className='rounded-lg border border-[color-mix(in_srgb,var(--text-error)_40%,transparent)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] px-2.5 py-2 text-[var(--text-error)] text-small'>
{detailsError}
</div>
)}
@@ -1299,7 +1301,7 @@ export function CredentialsManager() {
</AvatarFallback>
</Avatar>
<div className='min-w-0'>
<p className='truncate font-medium text-[var(--text-primary)] text-sm'>
<p className='truncate font-medium text-[var(--text-primary)] text-small'>
{member.userName || member.userEmail || member.userId}
</p>
<p className='truncate text-[var(--text-tertiary)] text-caption'>
@@ -801,15 +801,16 @@ export function IntegrationsManager() {
{filteredServices.map((service) => {
const config = getServiceConfigByProviderId(service.value)
return (
<button
<Button
key={service.value}
type='button'
variant='ghost'
onClick={() => {
setCreateOAuthProviderId(service.value)
setCreateStep(2)
setServiceSearch('')
}}
className='flex items-center gap-2.5 rounded-[6px] px-2 py-2 text-left hover:bg-[var(--surface-5)]'
className='h-auto w-full justify-start gap-2.5 rounded-[6px] px-2 py-2 text-left hover-hover:bg-[var(--surface-5)]'
>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[6px] bg-[var(--surface-5)]'>
{config ? (
@@ -823,7 +824,7 @@ export function IntegrationsManager() {
<span className='font-medium text-[15px] text-[var(--text-primary)]'>
{service.label}
</span>
</button>
</Button>
)
})}
{filteredServices.length === 0 && (
@@ -844,17 +845,18 @@ export function IntegrationsManager() {
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<button
<Button
type='button'
variant='ghost'
onClick={() => {
setCreateStep(1)
setCreateError(null)
}}
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
className='h-6 w-6 rounded-[4px] p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
aria-label='Back'
>
←
</button>
</Button>
<span>
Connect{' '}
{selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
@@ -970,17 +972,18 @@ export function IntegrationsManager() {
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<button
<Button
type='button'
variant='ghost'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
className='h-6 w-6 rounded-[4px] p-0 text-[var(--text-muted)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
aria-label='Back'
>
←
</button>
</Button>
<span>
Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
</span>
@@ -1146,10 +1149,10 @@ export function IntegrationsManager() {
? This action cannot be undone.
</p>
{deleteError && (
<div className='mt-3 rounded-lg border border-red-500/50 bg-red-50 p-3 dark:bg-red-950/30'>
<div className='mt-3 rounded-lg border border-[color-mix(in_srgb,var(--text-error)_40%,transparent)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] p-3'>
<div className='flex items-start gap-2.5'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400' />
<p className='text-red-700 text-small dark:text-red-300'>{deleteError}</p>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-[var(--text-error)]' />
<p className='text-[var(--text-error)] text-small'>{deleteError}</p>
</div>
</div>
)}
@@ -1240,9 +1243,10 @@ export function IntegrationsManager() {
Display Name
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
<Button
type='button'
className='-my-1 flex h-5 w-5 items-center justify-center'
variant='ghost'
className='-my-1 h-5 w-5 p-0'
onClick={() => {
navigator.clipboard.writeText(selectedCredential.id)
setCopyIdSuccess(true)
@@ -1251,11 +1255,11 @@ export function IntegrationsManager() {
aria-label='Copy value'
>
{copyIdSuccess ? (
<Check className='h-3 w-3 text-green-500' />
<Check className='h-3 w-3 text-[var(--text-success)]' />
) : (
<Clipboard className='h-3 w-3 text-[var(--text-icon)]' />
)}
</button>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{copyIdSuccess ? 'Copied!' : 'Copy credential ID'}
@@ -1288,7 +1292,7 @@ export function IntegrationsManager() {
</div>
{detailsError && (
<div className='rounded-lg border border-[color-mix(in_srgb,var(--status-red)_40%,transparent)] bg-[color-mix(in_srgb,var(--status-red)_10%,transparent)] px-2.5 py-2 text-[var(--status-red)] text-small'>
<div className='rounded-lg border border-[color-mix(in_srgb,var(--text-error)_40%,transparent)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] px-2.5 py-2 text-[var(--text-error)] text-small'>
{detailsError}
</div>
)}
@@ -1135,12 +1135,15 @@ function TeamPlanModal({ open, onOpenChange, isAnnual, onConfirm }: TeamPlanModa
const [selectedTier, setSelectedTier] = useState<number>(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<string | null>(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
@@ -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<string, number>) => {
function handleColumnWidthsChange(widths: Record<string, number>) {
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 (
<th
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
@@ -107,7 +107,7 @@ export function ImportCsvDialog({
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) void handleFileSelected(file)
},
[handleFileSelected]
)
function handleFileInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) void handleFileSelected(file)
}
const handleDragEnter = useCallback((e: React.DragEvent<HTMLButtonElement>) => {
function handleDragEnter(e: React.DragEvent<HTMLButtonElement>) {
e.preventDefault()
setIsDragging(true)
}, [])
}
const handleDragOver = useCallback((e: React.DragEvent<HTMLButtonElement>) => {
function handleDragOver(e: React.DragEvent<HTMLButtonElement>) {
e.preventDefault()
}, [])
}
const handleDragLeave = useCallback((e: React.DragEvent<HTMLButtonElement>) => {
function handleDragLeave(e: React.DragEvent<HTMLButtonElement>) {
e.preventDefault()
setIsDragging(false)
}, [])
}
const handleDrop = useCallback(
(e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files?.[0]
if (file) void handleFileSelected(file)
},
[handleFileSelected]
)
function handleDrop(e: React.DragEvent<HTMLButtonElement>) {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files?.[0]
if (file) void handleFileSelected(file)
}
const handleMappingChange = useCallback((header: string, value: string) => {
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<string, string[]>()
@@ -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 (
<Modal open={open} onOpenChange={onOpenChange}>
<Modal open={open} onOpenChange={handleOpenChange}>
<ModalContent size='lg'>
<ModalHeader>Import CSV into {table.name}</ModalHeader>
<ModalBody>
@@ -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 (
<div className='relative w-full'>
<Combobox
options={options}
value={localInputValue}
value={comboboxValue}
selectedValue={selectedValue}
onChange={(newValue) => {
// 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)
}}
@@ -573,6 +573,7 @@ export function MessagesInput({
<Popover
open={openPopoverIndex === index}
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
colorScheme='inverted'
>
<PopoverTrigger asChild>
<button
@@ -347,7 +347,7 @@ export const ShortInput = memo(function ShortInput({
<>
<Input
ref={ref as React.RefObject<HTMLInputElement>}
className='allow-scroll w-full overflow-auto text-transparent selection:text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] selection:text-transparent placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
readOnly={readOnly}
placeholder={placeholder ?? ''}
type='text'
@@ -1792,6 +1792,7 @@ export const ToolInput = memo(function ToolInput({
<Popover
open={usageControlPopoverIndex === toolIndex}
onOpenChange={(open) => setUsageControlPopoverIndex(open ? toolIndex : null)}
colorScheme='inverted'
>
<PopoverTrigger asChild>
<button
@@ -217,11 +217,9 @@ function CollapsibleSection({
</span>
<ChevronDown
className={cn(
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]',
isExpanded && 'rotate-180'
)}
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
</div>
@@ -663,7 +661,7 @@ function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayPro
disabled
className='mb-1'
/>
<div className='text-micro text-muted-foreground'>
<div className='text-[var(--text-tertiary)] text-micro'>
Enter a number between 1 and {config.maxIterations}
</div>
</div>
@@ -1091,7 +1089,7 @@ function PreviewEditorContent({
const subflowName = block.name || (isLoop ? 'Loop' : 'Parallel')
return (
<div className='relative flex h-full w-80 flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='relative flex h-full w-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like subflow header */}
<div className='mx-[-1px] flex flex-shrink-0 items-center gap-2 rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-3 py-1.5'>
<div
@@ -1125,7 +1123,7 @@ function PreviewEditorContent({
if (!blockConfig) {
return (
<div className='flex h-full w-80 flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='flex h-full w-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='mx-[-1px] flex items-center gap-2 rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-3 py-1.5'>
<div className='flex h-[18px] w-[18px] items-center justify-center rounded-sm bg-[var(--surface-3)]' />
<span className='font-medium text-[var(--text-primary)] text-sm'>
@@ -1180,7 +1178,7 @@ function PreviewEditorContent({
: 'gray'
return (
<div className='relative flex h-full w-80 flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
<div className='relative flex h-full w-full flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like editor */}
<div className='mx-[-1px] flex flex-shrink-0 items-center gap-2 rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-3 py-1.5'>
{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 }}
>
<IconComponent
icon={blockConfig.icon}
className='h-[12px] w-[12px] text-[var(--white)]'
/>
<IconComponent icon={blockConfig.icon} className='h-[12px] w-[12px] text-white' />
</div>
)}
<span className='min-w-0 flex-1 truncate font-medium text-[var(--text-primary)] text-sm'>
@@ -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))',
@@ -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<string | null>(() => {
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 currentStackEntry =
workflowStack.length > 0 ? workflowStack[workflowStack.length - 1] : null
const blockExecutions = currentStackEntry
? currentStackEntry.blockExecutions
: rootBlockExecutions
const workflowState = currentStackEntry ? currentStackEntry.workflowState : rootWorkflowState
const workflowState = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].workflowState
}
return rootWorkflowState
}, [workflowStack, rootWorkflowState])
const isExecutionMode = Object.keys(blockExecutions).length > 0
const isExecutionMode = useMemo(() => {
return Object.keys(blockExecutions).length > 0
}, [blockExecutions])
function handleDrillDown(blockId: string, childWorkflowState: WorkflowState) {
const blockExecution = blockExecutions[blockId]
const childTraceSpans = extractChildTraceSpans(blockExecution)
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
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'
const workflowName =
childWorkflowState.metadata?.name ||
(blockExecution?.output as { childWorkflowName?: string } | undefined)?.childWorkflowName ||
'Nested Workflow'
setWorkflowStack((prev) => [
...prev,
{
workflowState: childWorkflowState,
traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions,
workflowName,
},
])
setWorkflowStack((prev) => [
...prev,
{
workflowState: childWorkflowState,
traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions,
workflowName,
},
])
const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId)
}
const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId)
},
[blockExecutions]
)
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({
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode={isExecutionMode}
childWorkflowSnapshots={childWorkflowSnapshots}
onClose={handleEditorClose}
onDrillDown={handleDrillDown}
/>
<div style={{ width: panelWidth }} className='relative h-full flex-shrink-0'>
{/* Left-edge resize handle */}
<div
className='absolute top-0 bottom-0 left-0 z-10 w-1 cursor-ew-resize transition-colors hover-hover:bg-[var(--border-1)]'
onMouseDown={handleResizeMouseDown}
/>
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode={isExecutionMode}
childWorkflowSnapshots={childWorkflowSnapshots}
onClose={showBlockCloseButton ? handleEditorClose : undefined}
onDrillDown={handleDrillDown}
/>
</div>
)}
</div>
)
@@ -168,27 +168,24 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
}) {
const dragGhostRef = useRef<HTMLElement | null>(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 (
<SidebarTooltip label={task.name} enabled={showCollapsedTooltips}>
+7 -7
View File
@@ -5,16 +5,16 @@ import type { SapProxyResponse } from '@/tools/sap_s4hana/types'
export const SapS4HanaBlock: BlockConfig<SapProxyResponse> = {
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<SapProxyResponse> = {
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,
@@ -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: {
@@ -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),
})
+64 -49
View File
@@ -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<string, any> = {}
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<NormalizedBlockOutput> {
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> {
): 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> {
): 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(
+110 -11
View File
@@ -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, unknown> | 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<string, unknown>
input?: Record<string, unknown>
result?: Record<string, unknown>
output?: Record<string, unknown>
}
/** 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<string, unknown>
error?: string
/** Whether this error was handled by an error handler path (error port) */
errorHandled?: boolean
+7 -5
View File
@@ -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),
})
+3 -3
View File
@@ -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),
})
+18 -16
View File
@@ -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 })
},
})
}
+4 -4
View File
@@ -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),
})
+6 -6
View File
@@ -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),
})
+15 -15
View File
@@ -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),
})
+37 -7
View File
@@ -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<WorkflowLog> {
export async function fetchLogDetail(logId: string, signal?: AbortSignal): Promise<WorkflowLog> {
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() })
},
})
}
+38 -18
View File
@@ -7,12 +7,14 @@ const BASE = '/api/admin/mothership'
async function mothershipPost(
endpoint: string,
environment: MothershipEnv,
body?: Record<string, unknown>
body?: Record<string, unknown>,
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<string, string>
params?: Record<string, string>,
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,
})
}
+9 -9
View File
@@ -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<string, unknown>
}>
},
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() })
},
+3 -3
View File
@@ -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),
})
@@ -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[] = []
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,
}))
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
}
function extractOutputAndError(executionData: any): {
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: { traceSpans?: TraceSpan[] } & Record<string, unknown>
): {
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
@@ -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')
+3
View File
@@ -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 {
+80 -107
View File
@@ -61,43 +61,6 @@ export async function getUserWorkspaceIds(userId: string): Promise<string[]> {
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
}
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
}
}
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)
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,
}))
)
)
continue
}
.onConflictDoNothing()
}
await db
.delete(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_personal'),
eq(credential.envOwnerUserId, userId)
)
)
}
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)
)
)
} else {
await db
.delete(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_personal'),
eq(credential.envOwnerUserId, userId)
)
)
}
})
)
}
export async function getAccessibleEnvCredentials(
+4 -11
View File
@@ -47,17 +47,6 @@ const TRIGGER_COUNTER_MAP: Record<string, { key: string; column: string }> = {
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<string, any>
output?: Record<string, any>
error?: string
}
const logger = createLogger('ExecutionLogger')
function countTraceSpans(traceSpans?: TraceSpan[]): number {
@@ -84,6 +73,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
models: NonNullable<WorkflowExecutionLog['executionData']['models']>
}
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
+44 -38
View File
@@ -285,6 +285,46 @@ export class LoggingSession {
blockType: string,
output: any
): Promise<void> {
// 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<void> {
@@ -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<string, number>
parallelNumbers: Map<string, number>
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<number, TraceSpan[]>()
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<string>()
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<string, number>(),
parallelNumbers: new Map<string, number>(),
loopCounter: 1,
parallelCounter: 1,
}
return groupIterationBlocksRecursive(spans, normalSpans, counters)
}
@@ -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<string, unknown> {
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<string, number>()
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<string, BlockToolCall[]> {
const byName = new Map<string, BlockToolCall[]>()
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<string, unknown>
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
}
@@ -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')
})
})
@@ -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<string, unknown>
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<string, TraceSpan>()
const spans = buildRootSpansFromLogs(result.logs)
const grouped = groupIterationBlocks(spans)
const parentChildMap = new Map<string, string>()
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)
}
})
if (grouped.length === 0 || !result.metadata) {
const totalDuration = grouped.reduce((sum, span) => sum + span.duration, 0)
return { traceSpans: grouped, totalDuration }
}
result.logs.forEach((log) => {
if (!log.blockId || !log.blockType) return
return wrapInWorkflowRoot(grouped, spans)
}
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<string, unknown>
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<string, Array<Record<string, unknown>>>()
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<string, number>()
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<string, unknown>
input?: Record<string, unknown>
result?: Record<string, unknown>
output?: Record<string, unknown>
}
| 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<string, unknown>
input?: Record<string, unknown>
result?: Record<string, unknown>
output?: Record<string, unknown>
}>) {
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<string, unknown>
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()
}
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)
}
})
/** 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)
}
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 { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration }
}
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)
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 hasErrors = opts.sourceSpans.some((s) => s.status === 'error')
const allErrorsHandled =
hasErrors && opts.children.every((s) => s.status !== 'error' || s.errorHandled)
const actualWorkflowDuration = latestEnd - earliestStart
addRelativeTimestamps(grouped, earliestStart)
return {
id: opts.id,
name: opts.name,
type: opts.type,
duration: latestEnd - 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 } }),
}
return { traceSpans: [workflowSpan], totalDuration: actualWorkflowDuration }
}
/** Counter state for generating sequential container names. */
interface ContainerNameCounters {
loopNumbers: Map<string, number>
parallelNumbers: Map<string, number>
loopCounter: number
parallelCounter: number
}
/**
* 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<number, TraceSpan[]>()
/** Recursively annotates spans with `relativeStartMs` (ms since workflow start). */
function addRelativeTimestamps(spans: TraceSpan[], workflowStartMs: number): void {
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
span.relativeStartMs = new Date(span.startTime).getTime() - workflowStartMs
if (span.children?.length) {
addRelativeTimestamps(span.children, workflowStartMs)
}
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[] = []
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<string>()
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: [],
})
}
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<string, number>(),
parallelNumbers: new Map<string, number>(),
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
}
+52 -16
View File
@@ -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<WorkflowExecutionLog, 'id' | 'createdAt'>
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<string, unknown>
@@ -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 {
+12 -5
View File
@@ -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
+5 -2
View File
@@ -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<BlockTokens, 'total' | 'input' | 'output'>
): 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
}
/**
+123 -6
View File
@@ -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<string, unknown>)
: {},
}))
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 }),
}
}
+18 -3
View File
@@ -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) {
+74 -4
View File
@@ -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<string, unknown>)
: {},
}
})
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(
+34 -4
View File
@@ -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.
*/
+22 -3
View File
@@ -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 })
}
+34 -3
View File
@@ -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.
*/
+97 -4
View File
@@ -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<Part['functionCall']> } =>
Boolean(p.functionCall)
)
.map((p) => ({
id: p.functionCall.id ?? '',
name: p.functionCall.name ?? '',
arguments: (p.functionCall.args ?? {}) as Record<string, unknown>,
}))
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,
})
}
+22 -3
View File
@@ -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 })
}
+27 -3
View File
@@ -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.
*/
+27 -3
View File
@@ -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.
*/
+115 -4
View File
@@ -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<string, unknown>
@@ -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,
})
}
+23
View File
@@ -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.
*/
+34 -3
View File
@@ -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.
*/
+221
View File
@@ -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<ChatCompletionToolCallLike> | 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, unknown> | string {
try {
const parsed = JSON.parse(rawArguments)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
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<ChatCompletionLike['choices'][number]>['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 }
+7 -8
View File
@@ -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
+27 -3
View File
@@ -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.
*/
+27 -3
View File
@@ -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.
*/
+4 -57
View File
@@ -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<string, unknown> // Input parameters (optional)
output?: Record<string, unknown> // 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<string, unknown> // Added to store input data for this span
output?: Record<string, unknown> // 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
+5
View File
@@ -26,6 +26,11 @@ export const useLogDetailsUIStore = create<LogDetailsUIState>()(
{
name: 'log-details-ui-state',
partialize: (state) => ({ panelWidth: state.panelWidth }),
onRehydrateStorage: () => (state) => {
if (state) {
state.panelWidth = clampPanelWidth(state.panelWidth)
}
},
}
)
)
+2 -2
View File
@@ -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
/**