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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/realtime/src/database/operations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -768,6 +768,35 @@ async function handleBlockOperationTx(
break
}

case BLOCK_OPERATIONS.UPDATE_RETRY: {
if (!payload.id || payload.retry === undefined) {
throw new Error('Missing required fields for update retry operation')
}

const updateResult = await tx
.update(workflowBlocks)
.set({
/**
* Persisted verbatim, including a disabled policy, so the numbers a
* builder configured survive switching retry off and back on. NULL stays
* reserved for a block that never had a policy at all; whether a stored
* policy actually runs is decided by `resolveBlockRetryConfig` at
* execution time, never by the column being present.
*/
retry: payload.retry,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
.returning({ id: workflowBlocks.id })

if (updateResult.length === 0) {
throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`)
}

logger.debug(`Updated block retry: ${payload.id} -> ${payload.retry.enabled}`)
break
}

case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: {
if (!payload.id || !payload.canonicalId || !payload.canonicalMode) {
throw new Error('Missing required fields for update canonical mode operation')
Expand DownExpand Up@@ -962,6 +991,7 @@ async function handleBlocksOperationTx(
advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false,
errorEnabled: (block.errorEnabled as boolean) ?? false,
retry: (block.retry as Record<string, unknown> | undefined) ?? null,
height: (block.height as number) || 0,
locked: (block.locked as boolean) ?? false,
}
Expand All@@ -981,6 +1011,7 @@ async function handleBlocksOperationTx(
horizontalHandles: sql`excluded.horizontal_handles`,
advancedMode: sql`excluded.advanced_mode`,
triggerMode: sql`excluded.trigger_mode`,
retry: sql`excluded.retry`,
locked: sql`excluded.locked`,
height: sql`excluded.height`,
subBlocks: sql`excluded.sub_blocks`,
Expand DownExpand Up@@ -2172,6 +2203,7 @@ async function handleWorkflowOperationTx(
positionX: block.position.x,
positionY: block.position.y,
errorEnabled: block.errorEnabled ?? false,
retry: block.retry ?? null,
data: block.data || {},
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
Expand Down
13 changes: 12 additions & 1 deletion apps/realtime/src/middleware/permissions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* - Edge cases and invalid inputs
*/

import { ALL_SOCKET_OPERATIONS } from '@sim/realtime-protocol/constants'
import { ALL_SOCKET_OPERATIONS, BLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
import {
expectPermissionAllowed,
expectPermissionDenied,
Expand DownExpand Up@@ -232,6 +232,17 @@ describe('checkRolePermission', () => {
expect(adminOnly.length).toBeGreaterThan(0)
})

it('grants write every per-block operation the protocol declares', () => {
// A block operation that reaches this gate is an ordinary editor edit, so the
// write role must hold all of them. Without this, adding a block setting to
// the protocol and forgetting the ACL entry fails silently at runtime: the
// editor applies the change optimistically and the server drops the write.
const denied = Object.values(BLOCK_OPERATIONS).filter(
(operation) => !checkRolePermission('write', operation).allowed
)
expect(denied).toEqual([])
})

it('grants read nothing, so it is trivially a subset of write', () => {
const readAllowed = ALL_SOCKET_OPERATIONS.filter(
(operation) => checkRolePermission('read', operation).allowed
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/middleware/permissions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ const WRITE_OPERATIONS: string[] = [
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED,
BLOCK_OPERATIONS.UPDATE_RETRY,
BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -673,6 +673,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
})
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
if (span.tries !== undefined) metaEntries.push({ label: 'Tries', value: String(span.tries) })
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
if (span.model) metaEntries.push({ label: 'Model', value: span.model })
if (span.finishReason) metaEntries.push({ label: 'Finish reason', value: span.finishReason })
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
export { ConnectionBlocks } from './connection-blocks/connection-blocks'
export { RetrySettings } from './retry-settings/retry-settings'
export { SubBlock } from './sub-block/sub-block'
export { SubflowEditor } from './subflow-editor/subflow-editor'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RetrySettings } from './retry-settings'

const policy = { enabled: true as const, maxTries: 5, waitBetweenTriesMs: 2000 }

let container: HTMLDivElement
let root: Root

beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

function renderSettings(props: Partial<Parameters<typeof RetrySettings>[0]> = {}) {
const onChange = vi.fn()
act(() => {
root.render(<RetrySettings retry={policy} disabled={false} onChange={onChange} {...props} />)
})
return { onChange }
}

const field = (id: string) => container.querySelector<HTMLInputElement>(`#${id}`)

describe('RetrySettings', () => {
it('leaves a configured value alone when the field is blurred untouched', () => {
const { onChange } = renderSettings()
const maxTries = field('block-retry-max-tries')!
expect(maxTries.value).toBe('5')

act(() => {
maxTries.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
})

expect(onChange).not.toHaveBeenCalled()
expect(field('block-retry-max-tries')!.value).toBe('5')
})

it('renders only the switch while retry is off', () => {
renderSettings({ retry: { ...policy, enabled: false } })

expect(field('block-retry-enabled')).not.toBeNull()
expect(field('block-retry-max-tries')).toBeNull()
})
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
'use client'

import { useState } from 'react'
import { ChipInput, FieldDivider, Label, Switch } from '@sim/emcn'
import {
BLOCK_RETRY_DEFAULT_TRIES,
BLOCK_RETRY_DEFAULT_WAIT_MS,
type BlockRetryConfig,
normalizeBlockRetryTries,
normalizeBlockRetryWaitMs,
} from '@sim/workflow-types/workflow'

interface RetrySettingsProps {
retry: BlockRetryConfig | undefined
disabled: boolean
onChange: (retry: BlockRetryConfig) => void
}

interface RetryNumberFieldProps {
id: string
title: string
value: number
disabled: boolean
normalize: (value: unknown) => number
onCommit: (value: number) => void
}

/**
* A bounded number field that commits on blur.
*
* Typed as text with a numeric input mode rather than `type='number'`: the
* native spinner is all that buys, and it does not fit the field chrome the rest
* of the panel uses. Bounds are applied on commit through the same normalizer
* execution uses, so the field cannot clamp differently from the executor.
*
* The draft exists only while the field is being edited; clearing it on commit
* lets an external change — a collaborator's edit, or an undo — flow straight
* through on the next render with no resync.
*/
function RetryNumberField({
id,
title,
value,
disabled,
normalize,
onCommit,
}: RetryNumberFieldProps) {
const [draft, setDraft] = useState<string | null>(null)

const commit = () => {
/** Untouched field: nothing was typed, so there is nothing to normalize or write. */
if (draft === null) return
const next = normalize(draft)
setDraft(null)
if (next !== value) onCommit(next)
}
Comment thread
waleedlatif1 marked this conversation as resolved.

return (
<div className='subblock-content flex flex-col gap-2.5'>
<Label htmlFor={id}>{title}</Label>
<ChipInput
id={id}
type='text'
inputMode='numeric'
value={draft ?? String(value)}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
disabled={disabled}
/>
</div>
)
}

/**
* Per-block retry, rendered as ordinary rows among the block's other additional
* fields so it carries the same label, spacing, and dividers.
*
* The numbers stay mounted only while retry is on, but the policy is written
* with `enabled: false` when it is switched off, so turning it back on restores
* what was configured rather than snapping to the defaults.
*/
export function RetrySettings({ retry, disabled, onChange }: RetrySettingsProps) {
const enabled = retry?.enabled === true
const maxTries = retry?.maxTries ?? BLOCK_RETRY_DEFAULT_TRIES
const waitBetweenTriesMs = retry?.waitBetweenTriesMs ?? BLOCK_RETRY_DEFAULT_WAIT_MS

const setPolicy = (next: Partial<BlockRetryConfig>) =>
onChange({ enabled, maxTries, waitBetweenTriesMs, ...next })

return (
<>
<div className='subblock-row'>
<div className='subblock-content flex items-center gap-x-3'>
<Switch
id='block-retry-enabled'
checked={enabled}
onCheckedChange={(next) => setPolicy({ enabled: next })}
disabled={disabled}
/>
<Label htmlFor='block-retry-enabled'>Retry on fail</Label>
</div>
{enabled && <FieldDivider subblockMarker />}
</div>

{enabled && (
<>
<div className='subblock-row'>
<RetryNumberField
id='block-retry-max-tries'
title='Max tries'
value={maxTries}
disabled={disabled}
normalize={normalizeBlockRetryTries}
onCommit={(next) => setPolicy({ maxTries: next })}
/>
<FieldDivider subblockMarker />
</div>
<div className='subblock-row'>
<RetryNumberField
id='block-retry-wait'
title='Wait between tries (ms)'
value={waitBetweenTriesMs}
disabled={disabled}
normalize={normalizeBlockRetryWaitMs}
onCommit={(next) => setPolicy({ waitBetweenTriesMs: next })}
/>
</div>
</>
)}
</>
)
}
Loading
Loading