mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Claude Opus 4.7
parent
ed206ed7d8
commit
9a4cd95182
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
/>
|
||||
|
||||
+10
-14
@@ -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 = (
|
||||
<>
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+6
-3
@@ -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()
|
||||
|
||||
+5
-5
@@ -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'
|
||||
|
||||
+10
-8
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+254
-60
@@ -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}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TraceView } from './trace-view'
|
||||
+1183
File diff suppressed because it is too large
Load Diff
+375
-278
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
+18
-14
@@ -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>
|
||||
)}
|
||||
|
||||
+14
-17
@@ -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
|
||||
}
|
||||
|
||||
+92
-90
@@ -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'>
|
||||
|
||||
+21
-17
@@ -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>
|
||||
)}
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
+62
-79
@@ -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>
|
||||
|
||||
+8
-13
@@ -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)
|
||||
}}
|
||||
|
||||
+1
@@ -573,6 +573,7 @@ export function MessagesInput({
|
||||
<Popover
|
||||
open={openPopoverIndex === index}
|
||||
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+8
-13
@@ -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}>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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() })
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user