diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b8ba0146..e3b95c834 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,11 @@ jobs: steps: - name: Extract version from commit message id: extract + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} run: | - COMMIT_MSG="${{ github.event.head_commit.message }}" # Only tag versions on main branch - if [ "${{ github.ref }}" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then + if [ "$GITHUB_REF" = "refs/heads/main" ] && [[ "$COMMIT_MSG" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+): ]]; then VERSION="${BASH_REMATCH[1]}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "is_release=true" >> $GITHUB_OUTPUT diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 2968717af..53bc8db7f 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -119,6 +119,19 @@ aside#nd-sidebar { } } +/* Hide TOC popover on tablet/medium screens (768px - 1279px) */ +/* Keeps it visible on mobile (<768px) for easy navigation */ +/* Desktop (>=1280px) already hides it via fumadocs xl:hidden */ +@media (min-width: 768px) and (max-width: 1279px) { + #nd-docs-layout { + --fd-toc-popover-height: 0px !important; + } + + [data-toc-popover] { + display: none !important; + } +} + /* Desktop only: Apply custom navbar offset, sidebar width and margin offsets */ /* On mobile, let fumadocs handle the layout natively */ @media (min-width: 1024px) { diff --git a/apps/docs/content/docs/en/copilot/index.mdx b/apps/docs/content/docs/en/copilot/index.mdx index fdcdad8d5..e222d8e55 100644 --- a/apps/docs/content/docs/en/copilot/index.mdx +++ b/apps/docs/content/docs/en/copilot/index.mdx @@ -5,45 +5,25 @@ title: Copilot import { Callout } from 'fumadocs-ui/components/callout' import { Card, Cards } from 'fumadocs-ui/components/card' import { Image } from '@/components/ui/image' -import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react' +import { MessageCircle, Hammer, Zap, Globe, Paperclip, History, RotateCcw, Brain } from 'lucide-react' -Copilot is your in-editor assistant that helps you build and edit workflows with Sim Copilot, as well as understand and improve them. It can: +Copilot is your in-editor assistant that helps you build and edit workflows. It can: - **Explain**: Answer questions about Sim and your current workflow - **Guide**: Suggest edits and best practices -- **Edit**: Make changes to blocks, connections, and settings when you approve +- **Build**: Add blocks, wire connections, and configure settings +- **Debug**: Analyze execution issues and optimize performance - Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot) + Copilot is a Sim-managed service. For self-hosted deployments: 1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key - 2. Set `COPILOT_API_KEY` in your self-hosted environment to that value + 2. Set `COPILOT_API_KEY` in your self-hosted environment -## Context Menu (@) - -Use the `@` symbol to reference various resources and give Copilot more context about your workspace: - -Copilot context menu showing available reference options - -The `@` menu provides access to: -- **Chats**: Reference previous copilot conversations -- **All workflows**: Reference any workflow in your workspace -- **Workflow Blocks**: Reference specific blocks from workflows -- **Blocks**: Reference block types and templates -- **Knowledge**: Reference your uploaded documents and knowledgebase -- **Docs**: Reference Sim documentation -- **Templates**: Reference workflow templates -- **Logs**: Reference execution logs and results - -This contextual information helps Copilot provide more accurate and relevant assistance for your specific use case. - ## Modes +Switch between modes using the mode selector at the bottom of the input area. + - - Agent + + Build } >
- Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve. + Workflow building mode. Copilot can add blocks, wire connections, edit configurations, and debug issues.
-
- Copilot mode selection interface -
+## Models -## Depth Levels +Select your preferred AI model using the model selector at the bottom right of the input area. - - - - Fast - - } - > -
Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.
-
- - - Auto - - } - > -
Balanced speed and reasoning. Recommended default for most tasks.
-
- - - Advanced - - } - > -
More reasoning for larger workflows and complex edits while staying performant.
-
- - - Behemoth - - } - > -
Maximum reasoning for deep planning, debugging, and complex architectural changes.
-
-
+**Available Models:** +- Claude 4.5 Opus, Sonnet (default), Haiku +- GPT 5.2 Codex, Pro +- Gemini 3 Pro -### Mode Selection Interface +Choose based on your needs: faster models for simple tasks, more capable models for complex workflows. -You can easily switch between different reasoning modes using the mode selector in the Copilot interface: +## Context Menu (@) -Copilot mode selection showing Advanced mode with MAX toggle +Use the `@` symbol to reference resources and give Copilot more context: -The interface allows you to: -- **Select reasoning level**: Choose from Fast, Auto, Advanced, or Behemoth -- **Enable MAX mode**: Toggle for maximum reasoning capabilities when you need the most thorough analysis -- **See mode descriptions**: Understand what each mode is optimized for +| Reference | Description | +|-----------|-------------| +| **Chats** | Previous copilot conversations | +| **Workflows** | Any workflow in your workspace | +| **Workflow Blocks** | Blocks in the current workflow | +| **Blocks** | Block types and templates | +| **Knowledge** | Uploaded documents and knowledge bases | +| **Docs** | Sim documentation | +| **Templates** | Workflow templates | +| **Logs** | Execution logs and results | -Choose your mode based on the complexity of your task - use Fast for simple questions and Behemoth for complex architectural changes. +Type `@` in the input field to open the context menu, then search or browse to find what you need. -## Billing and Cost Calculation +## Slash Commands (/) -### How Costs Are Calculated +Use slash commands for quick actions: -Copilot usage is billed per token from the underlying LLM: +| Command | Description | +|---------|-------------| +| `/fast` | Fast mode execution | +| `/research` | Research and exploration mode | +| `/actions` | Execute agent actions | -- **Input tokens**: billed at the provider's base rate (**at-cost**) -- **Output tokens**: billed at **1.5×** the provider's base output rate +**Web Commands:** -```javascript -copilotCost = (inputTokens × inputPrice + outputTokens × (outputPrice × 1.5)) / 1,000,000 -``` +| Command | Description | +|---------|-------------| +| `/search` | Search the web | +| `/read` | Read a specific URL | +| `/scrape` | Scrape web page content | +| `/crawl` | Crawl multiple pages | -| Component | Rate Applied | -|----------|----------------------| -| Input | inputPrice | -| Output | outputPrice × 1.5 | +Type `/` in the input field to see available commands. - - Pricing shown reflects rates as of September 4, 2025. Check provider documentation for current pricing. - +## Chat Management + +### Starting a New Chat + +Click the **+** button in the Copilot header to start a fresh conversation. + +### Chat History + +Click **History** to view previous conversations grouped by date. You can: +- Click a chat to resume it +- Delete chats you no longer need + +### Editing Messages + +Hover over any of your messages and click **Edit** to modify and resend it. This is useful for refining your prompts. + +### Message Queue + +If you send a message while Copilot is still responding, it gets queued. You can: +- View queued messages in the expandable queue panel +- Send a queued message immediately (aborts current response) +- Remove messages from the queue + +## File Attachments + +Click the attachment icon to upload files with your message. Supported file types include: +- Images (preview thumbnails shown) +- PDFs +- Text files, JSON, XML +- Other document formats + +Files are displayed as clickable thumbnails that open in a new tab. + +## Checkpoints & Changes + +When Copilot makes changes to your workflow, it saves checkpoints so you can revert if needed. + +### Viewing Checkpoints + +Hover over a Copilot message and click the checkpoints icon to see saved workflow states for that message. + +### Reverting Changes + +Click **Revert** on any checkpoint to restore your workflow to that state. A confirmation dialog will warn that this action cannot be undone. + +### Accepting Changes + +When Copilot proposes changes, you can: +- **Accept**: Apply the proposed changes (`Mod+Shift+Enter`) +- **Reject**: Dismiss the changes and keep your current workflow + +## Thinking Blocks + +For complex requests, Copilot may show its reasoning process in expandable thinking blocks: + +- Blocks auto-expand while Copilot is thinking +- Click to manually expand/collapse +- Shows duration of the thinking process +- Helps you understand how Copilot arrived at its solution + +## Options Selection + +When Copilot presents multiple options, you can select using: + +| Control | Action | +|---------|--------| +| **1-9** | Select option by number | +| **Arrow Up/Down** | Navigate between options | +| **Enter** | Select highlighted option | + +Selected options are highlighted; unselected options appear struck through. + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `@` | Open context menu | +| `/` | Open slash commands | +| `Arrow Up/Down` | Navigate menu items | +| `Enter` | Select menu item | +| `Esc` | Close menus | +| `Mod+Shift+Enter` | Accept Copilot changes | + +## Usage Limits + +Copilot usage is billed per token from the underlying LLM. If you reach your usage limit, Copilot will prompt you to increase your limit. You can add usage in increments ($50, $100) from your current base. - Model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. See the Cost Calculation page for background and examples. + See the [Cost Calculation page](/execution/costs) for billing details. - diff --git a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx index 604883c42..94cc831ee 100644 --- a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx +++ b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx @@ -34,6 +34,8 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls | `Mod` + `V` | Paste blocks | | `Delete` or `Backspace` | Delete selected blocks or edges | | `Shift` + `L` | Auto-layout canvas | +| `Mod` + `Shift` + `F` | Fit to view | +| `Mod` + `Shift` + `Enter` | Accept Copilot changes | ## Panel Navigation diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 52213b66a..b934947a3 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -3,6 +3,7 @@ "pages": [ "./introduction/index", "./getting-started/index", + "./quick-reference/index", "triggers", "blocks", "tools", diff --git a/apps/docs/content/docs/en/quick-reference/index.mdx b/apps/docs/content/docs/en/quick-reference/index.mdx new file mode 100644 index 000000000..02126a624 --- /dev/null +++ b/apps/docs/content/docs/en/quick-reference/index.mdx @@ -0,0 +1,136 @@ +--- +title: Quick Reference +description: Essential actions for navigating and using the Sim workflow editor +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +A quick lookup for everyday actions in the Sim workflow editor. For keyboard shortcuts, see [Keyboard Shortcuts](/keyboard-shortcuts). + + + **Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux. + + +## Workspaces + +| Action | How | +|--------|-----| +| Create a workspace | Click workspace dropdown in sidebar → **New Workspace** | +| Rename a workspace | Workspace settings → Edit name | +| Switch workspaces | Click workspace dropdown in sidebar → Select workspace | +| Invite team members | Workspace settings → **Team** → **Invite** | + +## Workflows + +| Action | How | +|--------|-----| +| Create a workflow | Click **New Workflow** button or `Mod+Shift+A` | +| Rename a workflow | Double-click workflow name in sidebar, or right-click → **Rename** | +| Duplicate a workflow | Right-click workflow → **Duplicate** | +| Reorder workflows | Drag workflow up/down in the sidebar list | +| Import a workflow | Sidebar menu → **Import** → Select file | +| Create a folder | Right-click in sidebar → **New Folder** | +| Rename a folder | Right-click folder → **Rename** | +| Delete a folder | Right-click folder → **Delete** | +| Collapse/expand folder | Click folder arrow, or double-click folder | +| Move workflow to folder | Drag workflow onto folder in sidebar | +| Delete a workflow | Right-click workflow → **Delete** | +| Export a workflow | Right-click workflow → **Export** | +| Assign workflow color | Right-click workflow → **Change Color** | +| Multi-select workflows | `Mod+Click` or `Shift+Click` workflows in sidebar | +| Open in new tab | Right-click workflow → **Open in New Tab** | + +## Blocks + +| Action | How | +|--------|-----| +| Add a block | Drag from Toolbar panel, or right-click canvas → **Add Block** | +| Select a block | Click on the block | +| Multi-select blocks | `Mod+Click` additional blocks, or right-drag to draw selection box | +| Move blocks | Drag selected block(s) to new position | +| Copy blocks | `Mod+C` with blocks selected | +| Paste blocks | `Mod+V` to paste copied blocks | +| Duplicate blocks | Right-click → **Duplicate** | +| Delete blocks | `Delete` or `Backspace` key, or right-click → **Delete** | +| Rename a block | Click block name in header, or edit in the Editor panel | +| Enable/Disable a block | Right-click → **Enable/Disable** | +| Toggle handle orientation | Right-click → **Toggle Handles** | +| Toggle trigger mode | Right-click trigger block → **Toggle Trigger Mode** | +| Configure a block | Select block → use Editor panel on right | + +## Connections + +| Action | How | +|--------|-----| +| Create a connection | Drag from output handle to input handle | +| Delete a connection | Click edge to select → `Delete` key | +| Use output in another block | Drag connection tag into input field | + +## Canvas Navigation + +| Action | How | +|--------|-----| +| Pan/move canvas | Left-drag on empty space, or scroll/trackpad | +| Zoom in/out | Scroll wheel or pinch gesture | +| Auto-layout | `Shift+L` | +| Draw selection box | Right-drag on empty canvas area | + +## Panels & Views + +| Action | How | +|--------|-----| +| Open Copilot tab | Press `C` or click Copilot tab | +| Open Toolbar tab | Press `T` or click Toolbar tab | +| Open Editor tab | Press `E` or click Editor tab | +| Search toolbar | `Mod+F` | +| Toggle advanced mode | Click toggle button on input fields | +| Resize panels | Drag panel edge | +| Collapse/expand sidebar | Click collapse button on sidebar | + +## Running & Testing + +| Action | How | +|--------|-----| +| Run workflow | Click Play button or `Mod+Enter` | +| Stop workflow | Click Stop button or `Mod+Enter` while running | +| Test with chat | Use Chat panel on the right side | +| Select output to view | Click dropdown in Chat panel → Select block output | +| Clear chat history | Click clear button in Chat panel | +| View execution logs | Open terminal panel at bottom, or `Mod+L` | +| Filter logs by block | Click block filter in terminal | +| Filter logs by status | Click status filter in terminal | +| Search logs | Use search field in terminal | +| Copy log entry | Right-click log entry → **Copy** | +| Clear terminal | `Mod+D` | + +## Deployment + +| Action | How | +|--------|-----| +| Deploy a workflow | Click **Deploy** button in Deploy tab | +| Update deployment | Click **Update** when changes are detected | +| View deployment status | Check status indicator (Live/Update/Deploy) in Deploy tab | +| Revert deployment | Access previous versions in Deploy tab | +| Copy webhook URL | Deploy tab → Copy webhook URL | +| Copy API endpoint | Deploy tab → Copy API endpoint URL | +| Set up a schedule | Add Schedule trigger block → Configure interval | + +## Variables + +| Action | How | +|--------|-----| +| Add workflow variable | Variables tab → **Add Variable** | +| Edit workflow variable | Variables tab → Click variable to edit | +| Delete workflow variable | Variables tab → Click delete icon on variable | +| Add environment variable | Settings → **Environment Variables** → **Add** | +| Reference a variable | Use `{{variableName}}` syntax in block inputs | + +## Credentials + +| Action | How | +|--------|-----| +| Add API key | Block credential field → **Add Credential** → Enter API key | +| Connect OAuth account | Block credential field → **Connect** → Authorize with provider | +| Manage credentials | Settings → **Credentials** | +| Remove credential | Settings → **Credentials** → Delete credential | + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 274bba15b..017a51c0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -284,22 +284,37 @@ const renderLabel = ( )} {showCanonicalToggle && ( - + + + + + +

+ {canonicalToggle?.mode === 'advanced' + ? 'Switch to selector' + : 'Switch to manual ID'} +

+
+
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts index 5dade163b..9ecfe51f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts @@ -13,7 +13,11 @@ interface UseCanvasContextMenuProps { /** * Hook for managing workflow canvas context menus. - * Handles right-click events, menu state, click-outside detection, and block info extraction. + * + * Handles right-click events on nodes, pane, and selections with proper multi-select behavior. + * + * @param props - Hook configuration + * @returns Context menu state and handlers */ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasContextMenuProps) { const [activeMenu, setActiveMenu] = useState(null) @@ -46,19 +50,29 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo event.stopPropagation() const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey - setNodes((nodes) => - nodes.map((n) => ({ - ...n, - selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id, - })) - ) + const currentSelectedNodes = getNodes().filter((n) => n.selected) + const isClickedNodeSelected = currentSelectedNodes.some((n) => n.id === node.id) - const selectedNodes = getNodes().filter((n) => n.selected) - const nodesToUse = isMultiSelect - ? selectedNodes.some((n) => n.id === node.id) - ? selectedNodes - : [...selectedNodes, node] - : [node] + let nodesToUse: Node[] + if (isClickedNodeSelected) { + nodesToUse = currentSelectedNodes + } else if (isMultiSelect) { + nodesToUse = [...currentSelectedNodes, node] + setNodes((nodes) => + nodes.map((n) => ({ + ...n, + selected: n.id === node.id ? true : n.selected, + })) + ) + } else { + nodesToUse = [node] + setNodes((nodes) => + nodes.map((n) => ({ + ...n, + selected: n.id === node.id, + })) + ) + } setPosition({ x: event.clientX, y: event.clientY }) setSelectedBlocks(nodesToBlockInfos(nodesToUse)) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts index 35b8546b2..d87a3258d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts @@ -27,18 +27,13 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { const [isOpen, setIsOpen] = useState(false) const [position, setPosition] = useState({ x: 0, y: 0 }) const menuRef = useRef(null) - // Used to prevent click-outside dismissal when trigger is clicked const dismissPreventedRef = useRef(false) - /** - * Handle right-click event - */ const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - // Calculate position relative to viewport const x = e.clientX const y = e.clientY @@ -50,17 +45,10 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { [onContextMenu] ) - /** - * Close the context menu - */ const closeMenu = useCallback(() => { setIsOpen(false) }, []) - /** - * Prevent the next click-outside from dismissing the menu. - * Call this on pointerdown of a toggle trigger to allow proper toggle behavior. - */ const preventDismiss = useCallback(() => { dismissPreventedRef.current = true }, []) @@ -72,7 +60,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { if (!isOpen) return const handleClickOutside = (e: MouseEvent) => { - // Check if dismissal was prevented (e.g., by toggle trigger's pointerdown) if (dismissPreventedRef.current) { dismissPreventedRef.current = false return @@ -82,7 +69,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { } } - // Small delay to prevent immediate close from the same click that opened the menu const timeoutId = setTimeout(() => { document.addEventListener('click', handleClickOutside) }, 0) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 34de7723f..1e112d3fc 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -11,9 +11,10 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' +import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks/registry' -import type { SubBlockConfig } from '@/blocks/types' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { getUserPermissionConfig } from '@/executor/utils/permission-check' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' @@ -667,11 +668,47 @@ function createBlockFromParams( } } }) + + if (validatedInputs) { + updateCanonicalModesForInputs(blockState, Object.keys(validatedInputs), blockConfig) + } } return blockState } +function updateCanonicalModesForInputs( + block: { data?: { canonicalModes?: Record } }, + inputKeys: string[], + blockConfig: BlockConfig +): void { + if (!blockConfig.subBlocks?.length) return + + const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const canonicalModeUpdates: Record = {} + + for (const inputKey of inputKeys) { + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[inputKey] + if (!canonicalId) continue + + const group = canonicalIndex.groupsById[canonicalId] + if (!group || !isCanonicalPair(group)) continue + + const isAdvanced = group.advancedIds.includes(inputKey) + const existingMode = canonicalModeUpdates[canonicalId] + + if (!existingMode || isAdvanced) { + canonicalModeUpdates[canonicalId] = isAdvanced ? 'advanced' : 'basic' + } + } + + if (Object.keys(canonicalModeUpdates).length > 0) { + if (!block.data) block.data = {} + if (!block.data.canonicalModes) block.data.canonicalModes = {} + Object.assign(block.data.canonicalModes, canonicalModeUpdates) + } +} + /** * Normalize tools array by adding back fields that were sanitized for training */ @@ -1654,6 +1691,15 @@ function applyOperationsToWorkflowState( block.data.collection = params.inputs.collection } } + + const editBlockConfig = getBlock(block.type) + if (editBlockConfig) { + updateCanonicalModesForInputs( + block, + Object.keys(validationResult.validInputs), + editBlockConfig + ) + } } // Update basic properties @@ -2256,6 +2302,15 @@ function applyOperationsToWorkflowState( existingBlock.subBlocks[key].value = sanitizedValue } }) + + const existingBlockConfig = getBlock(existingBlock.type) + if (existingBlockConfig) { + updateCanonicalModesForInputs( + existingBlock, + Object.keys(validationResult.validInputs), + existingBlockConfig + ) + } } } else { // Special container types (loop, parallel) are not in the block registry but are valid diff --git a/bun.lock b/bun.lock index 4faa352e6..f1df7669e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/packages/python-sdk/.gitignore b/packages/python-sdk/.gitignore index db9927b25..ec1f692f4 100644 --- a/packages/python-sdk/.gitignore +++ b/packages/python-sdk/.gitignore @@ -81,4 +81,7 @@ Thumbs.db # mypy .mypy_cache/ .dmypy.json -dmypy.json \ No newline at end of file +dmypy.json + +# uv +uv.lock \ No newline at end of file diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index efcf91df7..e193e951c 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -43,24 +43,30 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai") #### Methods -##### execute_workflow(workflow_id, input_data=None, timeout=30.0) +##### execute_workflow(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None) Execute a workflow with optional input data. ```python -result = client.execute_workflow( - "workflow-id", - input_data={"message": "Hello, world!"}, - timeout=30.0 # 30 seconds -) +# With dict input (spread at root level of request body) +result = client.execute_workflow("workflow-id", {"message": "Hello, world!"}) + +# With primitive input (wrapped as { input: value }) +result = client.execute_workflow("workflow-id", "NVDA") + +# With options (keyword-only arguments) +result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60.0) ``` **Parameters:** - `workflow_id` (str): The ID of the workflow to execute -- `input_data` (dict, optional): Input data to pass to the workflow. File objects are automatically converted to base64. -- `timeout` (float): Timeout in seconds (default: 30.0) +- `input` (any, optional): Input data to pass to the workflow. Dicts are spread at the root level, primitives/lists are wrapped in `{ input: value }`. File objects are automatically converted to base64. +- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0) +- `stream` (bool, keyword-only): Enable streaming responses +- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`) +- `async_execution` (bool, keyword-only): Execute asynchronously and return execution ID -**Returns:** `WorkflowExecutionResult` +**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult` ##### get_workflow_status(workflow_id) @@ -92,24 +98,89 @@ if is_ready: **Returns:** `bool` -##### execute_workflow_sync(workflow_id, input_data=None, timeout=30.0) +##### execute_workflow_sync(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None) -Execute a workflow and poll for completion (useful for long-running workflows). +Execute a workflow synchronously (ensures non-async mode). ```python -result = client.execute_workflow_sync( +result = client.execute_workflow_sync("workflow-id", {"data": "some input"}, timeout=60.0) +``` + +**Parameters:** +- `workflow_id` (str): The ID of the workflow to execute +- `input` (any, optional): Input data to pass to the workflow +- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0) +- `stream` (bool, keyword-only): Enable streaming responses +- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`) + +**Returns:** `WorkflowExecutionResult` + +##### get_job_status(task_id) + +Get the status of an async job. + +```python +status = client.get_job_status("task-id-from-async-execution") +print("Job status:", status) +``` + +**Parameters:** +- `task_id` (str): The task ID returned from async execution + +**Returns:** `dict` + +##### execute_with_retry(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None, max_retries=3, initial_delay=1.0, max_delay=30.0, backoff_multiplier=2.0) + +Execute a workflow with automatic retry on rate limit errors. + +```python +result = client.execute_with_retry( "workflow-id", - input_data={"data": "some input"}, - timeout=60.0 + {"message": "Hello"}, + timeout=30.0, + max_retries=3, + initial_delay=1.0, + max_delay=30.0, + backoff_multiplier=2.0 ) ``` **Parameters:** - `workflow_id` (str): The ID of the workflow to execute -- `input_data` (dict, optional): Input data to pass to the workflow -- `timeout` (float): Timeout for the initial request in seconds +- `input` (any, optional): Input data to pass to the workflow +- `timeout` (float, keyword-only): Timeout in seconds (default: 30.0) +- `stream` (bool, keyword-only): Enable streaming responses +- `selected_outputs` (list, keyword-only): Block outputs to stream +- `async_execution` (bool, keyword-only): Execute asynchronously +- `max_retries` (int, keyword-only): Maximum retry attempts (default: 3) +- `initial_delay` (float, keyword-only): Initial delay in seconds (default: 1.0) +- `max_delay` (float, keyword-only): Maximum delay in seconds (default: 30.0) +- `backoff_multiplier` (float, keyword-only): Backoff multiplier (default: 2.0) -**Returns:** `WorkflowExecutionResult` +**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult` + +##### get_rate_limit_info() + +Get current rate limit information from the last API response. + +```python +rate_info = client.get_rate_limit_info() +if rate_info: + print("Remaining requests:", rate_info.remaining) +``` + +**Returns:** `RateLimitInfo` or `None` + +##### get_usage_limits() + +Get current usage limits and quota information. + +```python +limits = client.get_usage_limits() +print("Current usage:", limits.usage) +``` + +**Returns:** `UsageLimits` ##### set_api_key(api_key) @@ -171,6 +242,39 @@ class SimStudioError(Exception): self.status = status ``` +### AsyncExecutionResult + +```python +@dataclass +class AsyncExecutionResult: + success: bool + task_id: str + status: str # 'queued' + created_at: str + links: Dict[str, str] +``` + +### RateLimitInfo + +```python +@dataclass +class RateLimitInfo: + limit: int + remaining: int + reset: int + retry_after: Optional[int] = None +``` + +### UsageLimits + +```python +@dataclass +class UsageLimits: + success: bool + rate_limit: Dict[str, Any] + usage: Dict[str, Any] +``` + ## Examples ### Basic Workflow Execution @@ -191,7 +295,7 @@ def run_workflow(): # Execute the workflow result = client.execute_workflow( "my-workflow-id", - input_data={ + { "message": "Process this data", "user_id": "12345" } @@ -298,7 +402,7 @@ client = SimStudioClient(api_key=os.getenv("SIM_API_KEY")) with open('document.pdf', 'rb') as f: result = client.execute_workflow( 'workflow-id', - input_data={ + { 'documents': [f], # Must match your workflow's "files" field name 'instructions': 'Analyze this document' } @@ -308,7 +412,7 @@ with open('document.pdf', 'rb') as f: with open('doc1.pdf', 'rb') as f1, open('doc2.pdf', 'rb') as f2: result = client.execute_workflow( 'workflow-id', - input_data={ + { 'attachments': [f1, f2], # Must match your workflow's "files" field name 'query': 'Compare these documents' } @@ -327,14 +431,14 @@ def execute_workflows_batch(workflow_data_pairs): """Execute multiple workflows with different input data.""" results = [] - for workflow_id, input_data in workflow_data_pairs: + for workflow_id, workflow_input in workflow_data_pairs: try: # Validate workflow before execution if not client.validate_workflow(workflow_id): print(f"Skipping {workflow_id}: not deployed") continue - result = client.execute_workflow(workflow_id, input_data) + result = client.execute_workflow(workflow_id, workflow_input) results.append({ "workflow_id": workflow_id, "success": result.success, diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index 850a6b5e7..6812cc1d8 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "simstudio-sdk" -version = "0.1.1" +version = "0.1.2" authors = [ {name = "Sim", email = "help@sim.ai"}, ] diff --git a/packages/python-sdk/simstudio/__init__.py b/packages/python-sdk/simstudio/__init__.py index d20b7d7e9..ec242338e 100644 --- a/packages/python-sdk/simstudio/__init__.py +++ b/packages/python-sdk/simstudio/__init__.py @@ -13,7 +13,7 @@ import os import requests -__version__ = "0.1.0" +__version__ = "0.1.2" __all__ = [ "SimStudioClient", "SimStudioError", @@ -64,15 +64,6 @@ class RateLimitInfo: retry_after: Optional[int] = None -@dataclass -class RateLimitStatus: - """Rate limit status for sync/async requests.""" - is_limited: bool - limit: int - remaining: int - reset_at: str - - @dataclass class UsageLimits: """Usage limits and quota information.""" @@ -115,7 +106,6 @@ class SimStudioClient: Recursively processes nested dicts and lists. """ import base64 - import io # Check if this is a file-like object if hasattr(value, 'read') and callable(value.read): @@ -159,7 +149,8 @@ class SimStudioClient: def execute_workflow( self, workflow_id: str, - input_data: Optional[Dict[str, Any]] = None, + input: Optional[Any] = None, + *, timeout: float = 30.0, stream: Optional[bool] = None, selected_outputs: Optional[list] = None, @@ -169,11 +160,13 @@ class SimStudioClient: Execute a workflow with optional input data. If async_execution is True, returns immediately with a task ID. - File objects in input_data will be automatically detected and converted to base64. + File objects in input will be automatically detected and converted to base64. Args: workflow_id: The ID of the workflow to execute - input_data: Input data to pass to the workflow (can include file-like objects) + input: Input data to pass to the workflow. Can be a dict (spread at root level), + primitive value (string, number, bool), or list (wrapped in 'input' field). + File-like objects within dicts are automatically converted to base64. timeout: Timeout in seconds (default: 30.0) stream: Enable streaming responses (default: None) selected_outputs: Block outputs to stream (e.g., ["agent1.content"]) @@ -193,8 +186,15 @@ class SimStudioClient: headers['X-Execution-Mode'] = 'async' try: - # Build JSON body - spread input at root level, then add API control parameters - body = input_data.copy() if input_data is not None else {} + # Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field + body = {} + if input is not None: + if isinstance(input, dict): + # Dict input: spread at root level (matches curl/API behavior) + body = input.copy() + else: + # Primitive or list input: wrap in 'input' field + body = {'input': input} # Convert any file objects in the input to base64 format body = self._convert_files_to_base64(body) @@ -320,20 +320,18 @@ class SimStudioClient: def execute_workflow_sync( self, workflow_id: str, - input_data: Optional[Dict[str, Any]] = None, + input: Optional[Any] = None, + *, timeout: float = 30.0, stream: Optional[bool] = None, selected_outputs: Optional[list] = None ) -> WorkflowExecutionResult: """ - Execute a workflow and poll for completion (useful for long-running workflows). - - Note: Currently, the API is synchronous, so this method just calls execute_workflow. - In the future, if async execution is added, this method can be enhanced. + Execute a workflow synchronously (ensures non-async mode). Args: workflow_id: The ID of the workflow to execute - input_data: Input data to pass to the workflow (can include file-like objects) + input: Input data to pass to the workflow (can include file-like objects) timeout: Timeout for the initial request in seconds stream: Enable streaming responses (default: None) selected_outputs: Block outputs to stream (e.g., ["agent1.content"]) @@ -344,9 +342,14 @@ class SimStudioClient: Raises: SimStudioError: If the workflow execution fails """ - # For now, the API is synchronous, so we just execute directly - # In the future, if async execution is added, this method can be enhanced - return self.execute_workflow(workflow_id, input_data, timeout, stream, selected_outputs) + return self.execute_workflow( + workflow_id, + input, + timeout=timeout, + stream=stream, + selected_outputs=selected_outputs, + async_execution=False + ) def set_api_key(self, api_key: str) -> None: """ @@ -410,7 +413,8 @@ class SimStudioClient: def execute_with_retry( self, workflow_id: str, - input_data: Optional[Dict[str, Any]] = None, + input: Optional[Any] = None, + *, timeout: float = 30.0, stream: Optional[bool] = None, selected_outputs: Optional[list] = None, @@ -425,7 +429,7 @@ class SimStudioClient: Args: workflow_id: The ID of the workflow to execute - input_data: Input data to pass to the workflow (can include file-like objects) + input: Input data to pass to the workflow (can include file-like objects) timeout: Timeout in seconds stream: Enable streaming responses selected_outputs: Block outputs to stream @@ -448,11 +452,11 @@ class SimStudioClient: try: return self.execute_workflow( workflow_id, - input_data, - timeout, - stream, - selected_outputs, - async_execution + input, + timeout=timeout, + stream=stream, + selected_outputs=selected_outputs, + async_execution=async_execution ) except SimStudioError as e: if e.code != 'RATE_LIMIT_EXCEEDED': diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index faa3176a9..8dfdee99b 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -91,11 +91,9 @@ def test_context_manager(mock_close): """Test SimStudioClient as context manager.""" with SimStudioClient(api_key="test-api-key") as client: assert client.api_key == "test-api-key" - # Should close without error mock_close.assert_called_once() -# Tests for async execution @patch('simstudio.requests.Session.post') def test_async_execution_returns_task_id(mock_post): """Test async execution returns AsyncExecutionResult.""" @@ -115,7 +113,7 @@ def test_async_execution_returns_task_id(mock_post): client = SimStudioClient(api_key="test-api-key") result = client.execute_workflow( "workflow-id", - input_data={"message": "Hello"}, + {"message": "Hello"}, async_execution=True ) @@ -124,7 +122,6 @@ def test_async_execution_returns_task_id(mock_post): assert result.status == "queued" assert result.links["status"] == "/api/jobs/task-123" - # Verify X-Execution-Mode header was set call_args = mock_post.call_args assert call_args[1]["headers"]["X-Execution-Mode"] == "async" @@ -146,7 +143,7 @@ def test_sync_execution_returns_result(mock_post): client = SimStudioClient(api_key="test-api-key") result = client.execute_workflow( "workflow-id", - input_data={"message": "Hello"}, + {"message": "Hello"}, async_execution=False ) @@ -166,13 +163,12 @@ def test_async_header_not_set_when_false(mock_post): mock_post.return_value = mock_response client = SimStudioClient(api_key="test-api-key") - client.execute_workflow("workflow-id", input_data={"message": "Hello"}) + client.execute_workflow("workflow-id", {"message": "Hello"}) call_args = mock_post.call_args assert "X-Execution-Mode" not in call_args[1]["headers"] -# Tests for job status @patch('simstudio.requests.Session.get') def test_get_job_status_success(mock_get): """Test getting job status.""" @@ -222,7 +218,6 @@ def test_get_job_status_not_found(mock_get): assert "Job not found" in str(exc_info.value) -# Tests for retry with rate limiting @patch('simstudio.requests.Session.post') @patch('simstudio.time.sleep') def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post): @@ -238,7 +233,7 @@ def test_execute_with_retry_success_first_attempt(mock_sleep, mock_post): mock_post.return_value = mock_response client = SimStudioClient(api_key="test-api-key") - result = client.execute_with_retry("workflow-id", input_data={"message": "test"}) + result = client.execute_with_retry("workflow-id", {"message": "test"}) assert result.success is True assert mock_post.call_count == 1 @@ -278,7 +273,7 @@ def test_execute_with_retry_retries_on_rate_limit(mock_sleep, mock_post): client = SimStudioClient(api_key="test-api-key") result = client.execute_with_retry( "workflow-id", - input_data={"message": "test"}, + {"message": "test"}, max_retries=3, initial_delay=0.01 ) @@ -307,7 +302,7 @@ def test_execute_with_retry_max_retries_exceeded(mock_sleep, mock_post): with pytest.raises(SimStudioError) as exc_info: client.execute_with_retry( "workflow-id", - input_data={"message": "test"}, + {"message": "test"}, max_retries=2, initial_delay=0.01 ) @@ -333,13 +328,12 @@ def test_execute_with_retry_no_retry_on_other_errors(mock_post): client = SimStudioClient(api_key="test-api-key") with pytest.raises(SimStudioError) as exc_info: - client.execute_with_retry("workflow-id", input_data={"message": "test"}) + client.execute_with_retry("workflow-id", {"message": "test"}) assert "Server error" in str(exc_info.value) assert mock_post.call_count == 1 # No retries -# Tests for rate limit info def test_get_rate_limit_info_returns_none_initially(): """Test rate limit info is None before any API calls.""" client = SimStudioClient(api_key="test-api-key") @@ -362,7 +356,7 @@ def test_get_rate_limit_info_after_api_call(mock_post): mock_post.return_value = mock_response client = SimStudioClient(api_key="test-api-key") - client.execute_workflow("workflow-id", input_data={}) + client.execute_workflow("workflow-id", {}) info = client.get_rate_limit_info() assert info is not None @@ -371,7 +365,6 @@ def test_get_rate_limit_info_after_api_call(mock_post): assert info.reset == 1704067200 -# Tests for usage limits @patch('simstudio.requests.Session.get') def test_get_usage_limits_success(mock_get): """Test getting usage limits.""" @@ -435,7 +428,6 @@ def test_get_usage_limits_unauthorized(mock_get): assert "Invalid API key" in str(exc_info.value) -# Tests for streaming with selectedOutputs @patch('simstudio.requests.Session.post') def test_execute_workflow_with_stream_and_selected_outputs(mock_post): """Test execution with stream and selectedOutputs parameters.""" @@ -449,7 +441,7 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post): client = SimStudioClient(api_key="test-api-key") client.execute_workflow( "workflow-id", - input_data={"message": "test"}, + {"message": "test"}, stream=True, selected_outputs=["agent1.content", "agent2.content"] ) @@ -459,4 +451,85 @@ def test_execute_workflow_with_stream_and_selected_outputs(mock_post): assert request_body["message"] == "test" assert request_body["stream"] is True - assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"] \ No newline at end of file + assert request_body["selectedOutputs"] == ["agent1.content", "agent2.content"] + + +# Tests for primitive and list inputs +@patch('simstudio.requests.Session.post') +def test_execute_workflow_with_string_input(mock_post): + """Test execution with primitive string input wraps in input field.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True, "output": {}} + mock_response.headers.get.return_value = None + mock_post.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key") + client.execute_workflow("workflow-id", "NVDA") + + call_args = mock_post.call_args + request_body = call_args[1]["json"] + + assert request_body["input"] == "NVDA" + assert "0" not in request_body # Should not spread string characters + + +@patch('simstudio.requests.Session.post') +def test_execute_workflow_with_number_input(mock_post): + """Test execution with primitive number input wraps in input field.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True, "output": {}} + mock_response.headers.get.return_value = None + mock_post.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key") + client.execute_workflow("workflow-id", 42) + + call_args = mock_post.call_args + request_body = call_args[1]["json"] + + assert request_body["input"] == 42 + + +@patch('simstudio.requests.Session.post') +def test_execute_workflow_with_list_input(mock_post): + """Test execution with list input wraps in input field.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True, "output": {}} + mock_response.headers.get.return_value = None + mock_post.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key") + client.execute_workflow("workflow-id", ["NVDA", "AAPL", "GOOG"]) + + call_args = mock_post.call_args + request_body = call_args[1]["json"] + + assert request_body["input"] == ["NVDA", "AAPL", "GOOG"] + assert "0" not in request_body # Should not spread list + + +@patch('simstudio.requests.Session.post') +def test_execute_workflow_with_dict_input_spreads_at_root(mock_post): + """Test execution with dict input spreads at root level.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True, "output": {}} + mock_response.headers.get.return_value = None + mock_post.return_value = mock_response + + client = SimStudioClient(api_key="test-api-key") + client.execute_workflow("workflow-id", {"ticker": "NVDA", "quantity": 100}) + + call_args = mock_post.call_args + request_body = call_args[1]["json"] + + assert request_body["ticker"] == "NVDA" + assert request_body["quantity"] == 100 + assert "input" not in request_body # Should not wrap in input field \ No newline at end of file diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md index 04582aeda..44d21d0c9 100644 --- a/packages/ts-sdk/README.md +++ b/packages/ts-sdk/README.md @@ -47,24 +47,35 @@ new SimStudioClient(config: SimStudioConfig) #### Methods -##### executeWorkflow(workflowId, options?) +##### executeWorkflow(workflowId, input?, options?) Execute a workflow with optional input data. ```typescript +// With object input (spread at root level of request body) const result = await client.executeWorkflow('workflow-id', { - input: { message: 'Hello, world!' }, - timeout: 30000 // 30 seconds + message: 'Hello, world!' +}); + +// With primitive input (wrapped as { input: value }) +const result = await client.executeWorkflow('workflow-id', 'NVDA'); + +// With options +const result = await client.executeWorkflow('workflow-id', { message: 'Hello' }, { + timeout: 60000 }); ``` **Parameters:** - `workflowId` (string): The ID of the workflow to execute +- `input` (any, optional): Input data to pass to the workflow. Objects are spread at the root level, primitives/arrays are wrapped in `{ input: value }`. File objects are automatically converted to base64. - `options` (ExecutionOptions, optional): - - `input` (any): Input data to pass to the workflow. File objects are automatically converted to base64. - `timeout` (number): Timeout in milliseconds (default: 30000) + - `stream` (boolean): Enable streaming responses + - `selectedOutputs` (string[]): Block outputs to stream (e.g., `["agent1.content"]`) + - `async` (boolean): Execute asynchronously and return execution ID -**Returns:** `Promise` +**Returns:** `Promise` ##### getWorkflowStatus(workflowId) @@ -96,25 +107,89 @@ if (isReady) { **Returns:** `Promise` -##### executeWorkflowSync(workflowId, options?) +##### executeWorkflowSync(workflowId, input?, options?) Execute a workflow and poll for completion (useful for long-running workflows). ```typescript -const result = await client.executeWorkflowSync('workflow-id', { - input: { data: 'some input' }, +const result = await client.executeWorkflowSync('workflow-id', { data: 'some input' }, { timeout: 60000 }); ``` **Parameters:** - `workflowId` (string): The ID of the workflow to execute +- `input` (any, optional): Input data to pass to the workflow - `options` (ExecutionOptions, optional): - - `input` (any): Input data to pass to the workflow - `timeout` (number): Timeout for the initial request in milliseconds **Returns:** `Promise` +##### getJobStatus(taskId) + +Get the status of an async job. + +```typescript +const status = await client.getJobStatus('task-id-from-async-execution'); +console.log('Job status:', status); +``` + +**Parameters:** +- `taskId` (string): The task ID returned from async execution + +**Returns:** `Promise` + +##### executeWithRetry(workflowId, input?, options?, retryOptions?) + +Execute a workflow with automatic retry on rate limit errors. + +```typescript +const result = await client.executeWithRetry('workflow-id', { message: 'Hello' }, { + timeout: 30000 +}, { + maxRetries: 3, + initialDelay: 1000, + maxDelay: 30000, + backoffMultiplier: 2 +}); +``` + +**Parameters:** +- `workflowId` (string): The ID of the workflow to execute +- `input` (any, optional): Input data to pass to the workflow +- `options` (ExecutionOptions, optional): Execution options +- `retryOptions` (RetryOptions, optional): + - `maxRetries` (number): Maximum retry attempts (default: 3) + - `initialDelay` (number): Initial delay in ms (default: 1000) + - `maxDelay` (number): Maximum delay in ms (default: 30000) + - `backoffMultiplier` (number): Backoff multiplier (default: 2) + +**Returns:** `Promise` + +##### getRateLimitInfo() + +Get current rate limit information from the last API response. + +```typescript +const rateInfo = client.getRateLimitInfo(); +if (rateInfo) { + console.log('Remaining requests:', rateInfo.remaining); +} +``` + +**Returns:** `RateLimitInfo | null` + +##### getUsageLimits() + +Get current usage limits and quota information. + +```typescript +const limits = await client.getUsageLimits(); +console.log('Current usage:', limits.usage); +``` + +**Returns:** `Promise` + ##### setApiKey(apiKey) Update the API key. @@ -170,6 +245,81 @@ class SimStudioError extends Error { } ``` +### AsyncExecutionResult + +```typescript +interface AsyncExecutionResult { + success: boolean; + taskId: string; + status: 'queued'; + createdAt: string; + links: { + status: string; + }; +} +``` + +### RateLimitInfo + +```typescript +interface RateLimitInfo { + limit: number; + remaining: number; + reset: number; + retryAfter?: number; +} +``` + +### UsageLimits + +```typescript +interface UsageLimits { + success: boolean; + rateLimit: { + sync: { + isLimited: boolean; + limit: number; + remaining: number; + resetAt: string; + }; + async: { + isLimited: boolean; + limit: number; + remaining: number; + resetAt: string; + }; + authType: string; + }; + usage: { + currentPeriodCost: number; + limit: number; + plan: string; + }; +} +``` + +### ExecutionOptions + +```typescript +interface ExecutionOptions { + timeout?: number; + stream?: boolean; + selectedOutputs?: string[]; + async?: boolean; +} +``` + +### RetryOptions + +```typescript +interface RetryOptions { + maxRetries?: number; + initialDelay?: number; + maxDelay?: number; + backoffMultiplier?: number; +} +``` + ## Examples ### Basic Workflow Execution @@ -191,10 +341,8 @@ async function runWorkflow() { // Execute the workflow const result = await client.executeWorkflow('my-workflow-id', { - input: { - message: 'Process this data', - userId: '12345' - } + message: 'Process this data', + userId: '12345' }); if (result.success) { @@ -298,22 +446,18 @@ const file = new File([fileBuffer], 'document.pdf', { type: 'application/pdf' }) // Include files under the field name from your API trigger's input format const result = await client.executeWorkflow('workflow-id', { - input: { - documents: [file], // Field name must match your API trigger's file input field - instructions: 'Process this document' - } + documents: [file], // Field name must match your API trigger's file input field + instructions: 'Process this document' }); // Browser: From file input const handleFileUpload = async (event: Event) => { - const input = event.target as HTMLInputElement; - const files = Array.from(input.files || []); + const inputEl = event.target as HTMLInputElement; + const files = Array.from(inputEl.files || []); const result = await client.executeWorkflow('workflow-id', { - input: { - attachments: files, // Field name must match your API trigger's file input field - query: 'Analyze these files' - } + attachments: files, // Field name must match your API trigger's file input field + query: 'Analyze these files' }); }; ``` diff --git a/packages/ts-sdk/package.json b/packages/ts-sdk/package.json index f40d69516..649a8b76e 100644 --- a/packages/ts-sdk/package.json +++ b/packages/ts-sdk/package.json @@ -1,6 +1,6 @@ { "name": "simstudio-ts-sdk", - "version": "0.1.1", + "version": "0.1.2", "description": "Sim SDK - Execute workflows programmatically", "type": "module", "exports": { diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index e8ebbddad..063a31b32 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -119,10 +119,11 @@ describe('SimStudioClient', () => { } vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) - const result = await client.executeWorkflow('workflow-id', { - input: { message: 'Hello' }, - async: true, - }) + const result = await client.executeWorkflow( + 'workflow-id', + { message: 'Hello' }, + { async: true } + ) expect(result).toHaveProperty('taskId', 'task-123') expect(result).toHaveProperty('status', 'queued') @@ -152,10 +153,11 @@ describe('SimStudioClient', () => { } vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) - const result = await client.executeWorkflow('workflow-id', { - input: { message: 'Hello' }, - async: false, - }) + const result = await client.executeWorkflow( + 'workflow-id', + { message: 'Hello' }, + { async: false } + ) expect(result).toHaveProperty('success', true) expect(result).toHaveProperty('output') @@ -177,9 +179,7 @@ describe('SimStudioClient', () => { } vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) - await client.executeWorkflow('workflow-id', { - input: { message: 'Hello' }, - }) + await client.executeWorkflow('workflow-id', { message: 'Hello' }) const calls = vi.mocked(fetch.default).mock.calls expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode') @@ -256,9 +256,7 @@ describe('SimStudioClient', () => { } vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) - const result = await client.executeWithRetry('workflow-id', { - input: { message: 'test' }, - }) + const result = await client.executeWithRetry('workflow-id', { message: 'test' }) expect(result).toHaveProperty('success', true) expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) @@ -305,7 +303,8 @@ describe('SimStudioClient', () => { const result = await client.executeWithRetry( 'workflow-id', - { input: { message: 'test' } }, + { message: 'test' }, + {}, { maxRetries: 3, initialDelay: 10 } ) @@ -336,7 +335,8 @@ describe('SimStudioClient', () => { await expect( client.executeWithRetry( 'workflow-id', - { input: { message: 'test' } }, + { message: 'test' }, + {}, { maxRetries: 2, initialDelay: 10 } ) ).rejects.toThrow('Rate limit exceeded') @@ -361,9 +361,9 @@ describe('SimStudioClient', () => { vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) - await expect( - client.executeWithRetry('workflow-id', { input: { message: 'test' } }) - ).rejects.toThrow('Server error') + await expect(client.executeWithRetry('workflow-id', { message: 'test' })).rejects.toThrow( + 'Server error' + ) expect(vi.mocked(fetch.default)).toHaveBeenCalledTimes(1) // No retries }) @@ -393,7 +393,7 @@ describe('SimStudioClient', () => { vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) - await client.executeWorkflow('workflow-id', { input: {} }) + await client.executeWorkflow('workflow-id', {}) const info = client.getRateLimitInfo() expect(info).not.toBeNull() @@ -490,11 +490,11 @@ describe('SimStudioClient', () => { vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) - await client.executeWorkflow('workflow-id', { - input: { message: 'test' }, - stream: true, - selectedOutputs: ['agent1.content', 'agent2.content'], - }) + await client.executeWorkflow( + 'workflow-id', + { message: 'test' }, + { stream: true, selectedOutputs: ['agent1.content', 'agent2.content'] } + ) const calls = vi.mocked(fetch.default).mock.calls const requestBody = JSON.parse(calls[0][1]?.body as string) @@ -505,6 +505,134 @@ describe('SimStudioClient', () => { expect(requestBody.selectedOutputs).toEqual(['agent1.content', 'agent2.content']) }) }) + + describe('executeWorkflow - primitive and array inputs', () => { + it('should wrap primitive string input in input field', async () => { + const fetch = await import('node-fetch') + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + success: true, + output: {}, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + + vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) + + await client.executeWorkflow('workflow-id', 'NVDA') + + const calls = vi.mocked(fetch.default).mock.calls + const requestBody = JSON.parse(calls[0][1]?.body as string) + + expect(requestBody).toHaveProperty('input', 'NVDA') + expect(requestBody).not.toHaveProperty('0') // Should not spread string characters + }) + + it('should wrap primitive number input in input field', async () => { + const fetch = await import('node-fetch') + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + success: true, + output: {}, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + + vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) + + await client.executeWorkflow('workflow-id', 42) + + const calls = vi.mocked(fetch.default).mock.calls + const requestBody = JSON.parse(calls[0][1]?.body as string) + + expect(requestBody).toHaveProperty('input', 42) + }) + + it('should wrap array input in input field', async () => { + const fetch = await import('node-fetch') + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + success: true, + output: {}, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + + vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) + + await client.executeWorkflow('workflow-id', ['NVDA', 'AAPL', 'GOOG']) + + const calls = vi.mocked(fetch.default).mock.calls + const requestBody = JSON.parse(calls[0][1]?.body as string) + + expect(requestBody).toHaveProperty('input') + expect(requestBody.input).toEqual(['NVDA', 'AAPL', 'GOOG']) + expect(requestBody).not.toHaveProperty('0') // Should not spread array + }) + + it('should spread object input at root level', async () => { + const fetch = await import('node-fetch') + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + success: true, + output: {}, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + + vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) + + await client.executeWorkflow('workflow-id', { ticker: 'NVDA', quantity: 100 }) + + const calls = vi.mocked(fetch.default).mock.calls + const requestBody = JSON.parse(calls[0][1]?.body as string) + + expect(requestBody).toHaveProperty('ticker', 'NVDA') + expect(requestBody).toHaveProperty('quantity', 100) + expect(requestBody).not.toHaveProperty('input') // Should not wrap in input field + }) + + it('should handle null input as no input (empty body)', async () => { + const fetch = await import('node-fetch') + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + success: true, + output: {}, + }), + headers: { + get: vi.fn().mockReturnValue(null), + }, + } + + vi.mocked(fetch.default).mockResolvedValue(mockResponse as any) + + await client.executeWorkflow('workflow-id', null) + + const calls = vi.mocked(fetch.default).mock.calls + const requestBody = JSON.parse(calls[0][1]?.body as string) + + // null treated as "no input" - sends empty body (consistent with Python SDK) + expect(requestBody).toEqual({}) + }) + }) }) describe('SimStudioError', () => { diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index 2f4c555d8..e6e765786 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -26,7 +26,6 @@ export interface WorkflowStatus { } export interface ExecutionOptions { - input?: any timeout?: number stream?: boolean selectedOutputs?: string[] @@ -117,10 +116,6 @@ export class SimStudioClient { this.baseUrl = normalizeBaseUrl(config.baseUrl || 'https://sim.ai') } - /** - * Execute a workflow with optional input data - * If async is true, returns immediately with a task ID - */ /** * Convert File objects in input to API format (base64) * Recursively processes nested objects and arrays @@ -170,20 +165,25 @@ export class SimStudioClient { return value } + /** + * Execute a workflow with optional input data + * @param workflowId - The ID of the workflow to execute + * @param input - Input data to pass to the workflow (object, primitive, or array) + * @param options - Execution options (timeout, stream, async, etc.) + */ async executeWorkflow( workflowId: string, + input?: any, options: ExecutionOptions = {} ): Promise { const url = `${this.baseUrl}/api/workflows/${workflowId}/execute` - const { input, timeout = 30000, stream, selectedOutputs, async } = options + const { timeout = 30000, stream, selectedOutputs, async } = options try { - // Create a timeout promise const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('TIMEOUT')), timeout) }) - // Build headers - async execution uses X-Execution-Mode header const headers: Record = { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, @@ -192,10 +192,15 @@ export class SimStudioClient { headers['X-Execution-Mode'] = 'async' } - // Build JSON body - spread input at root level, then add API control parameters - let jsonBody: any = input !== undefined ? { ...input } : {} + let jsonBody: any = {} + if (input !== undefined && input !== null) { + if (typeof input === 'object' && input !== null && !Array.isArray(input)) { + jsonBody = { ...input } + } else { + jsonBody = { input } + } + } - // Convert any File objects in the input to base64 format jsonBody = await this.convertFilesToBase64(jsonBody) if (stream !== undefined) { @@ -213,10 +218,8 @@ export class SimStudioClient { const response = await Promise.race([fetchPromise, timeoutPromise]) - // Extract rate limit headers this.updateRateLimitInfo(response) - // Handle rate limiting with retry if (response.status === 429) { const retryAfter = this.rateLimitInfo?.retryAfter || 1000 throw new SimStudioError( @@ -285,15 +288,18 @@ export class SimStudioClient { } /** - * Execute a workflow and poll for completion (useful for long-running workflows) + * Execute a workflow synchronously (ensures non-async mode) + * @param workflowId - The ID of the workflow to execute + * @param input - Input data to pass to the workflow + * @param options - Execution options (timeout, stream, etc.) */ async executeWorkflowSync( workflowId: string, + input?: any, options: ExecutionOptions = {} ): Promise { - // Ensure sync mode by explicitly setting async to false const syncOptions = { ...options, async: false } - return this.executeWorkflow(workflowId, syncOptions) as Promise + return this.executeWorkflow(workflowId, input, syncOptions) as Promise } /** @@ -361,9 +367,14 @@ export class SimStudioClient { /** * Execute workflow with automatic retry on rate limit + * @param workflowId - The ID of the workflow to execute + * @param input - Input data to pass to the workflow + * @param options - Execution options (timeout, stream, async, etc.) + * @param retryOptions - Retry configuration (maxRetries, delays, etc.) */ async executeWithRetry( workflowId: string, + input?: any, options: ExecutionOptions = {}, retryOptions: RetryOptions = {} ): Promise { @@ -379,7 +390,7 @@ export class SimStudioClient { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - return await this.executeWorkflow(workflowId, options) + return await this.executeWorkflow(workflowId, input, options) } catch (error: any) { if (!(error instanceof SimStudioError) || error.code !== 'RATE_LIMIT_EXCEEDED') { throw error @@ -387,23 +398,19 @@ export class SimStudioClient { lastError = error - // Don't retry after last attempt if (attempt === maxRetries) { break } - // Use retry-after if provided, otherwise use exponential backoff const waitTime = error.status === 429 && this.rateLimitInfo?.retryAfter ? this.rateLimitInfo.retryAfter : Math.min(delay, maxDelay) - // Add jitter (±25%) const jitter = waitTime * (0.75 + Math.random() * 0.5) await new Promise((resolve) => setTimeout(resolve, jitter)) - // Exponential backoff for next attempt delay *= backoffMultiplier } } @@ -475,5 +482,4 @@ export class SimStudioClient { } } -// Export types and classes export { SimStudioClient as default } diff --git a/scripts/create-single-release.ts b/scripts/create-single-release.ts index 3e6d90c56..b49777cec 100755 --- a/scripts/create-single-release.ts +++ b/scripts/create-single-release.ts @@ -126,7 +126,7 @@ async function fetchGitHubCommitDetails( const githubUsername = commit.author?.login || commit.committer?.login || 'unknown' - let cleanMessage = commit.commit.message.split('\n')[0] // First line only + let cleanMessage = commit.commit.message.split('\n')[0] if (prNumber) { cleanMessage = cleanMessage.replace(/\s*\(#\d+\)\s*$/, '') } @@ -226,12 +226,23 @@ async function getCommitsBetweenVersions( function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements' | 'other' { const msgLower = message.toLowerCase() - if ( - msgLower.includes('feat') || - msgLower.includes('add') || - msgLower.includes('implement') || - msgLower.includes('new ') - ) { + if (/^feat(\(|:|!)/.test(msgLower)) { + return 'features' + } + + if (/^fix(\(|:|!)/.test(msgLower)) { + return 'fixes' + } + + if (/^(improvement|improve|perf|refactor)(\(|:|!)/.test(msgLower)) { + return 'improvements' + } + + if (/^(chore|docs|style|test|ci|build)(\(|:|!)/.test(msgLower)) { + return 'other' + } + + if (msgLower.includes('feat') || msgLower.includes('implement') || msgLower.includes('new ')) { return 'features' } @@ -242,9 +253,10 @@ function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements if ( msgLower.includes('improve') || msgLower.includes('enhance') || - msgLower.includes('update') || msgLower.includes('upgrade') || - msgLower.includes('optimization') + msgLower.includes('optimization') || + msgLower.includes('add') || + msgLower.includes('update') ) { return 'improvements' }