Checkpoint

This commit is contained in:
Siddharth Ganesan
2026-02-12 10:22:52 -08:00
parent c22bd2caaa
commit 76bd405293
20 changed files with 783 additions and 82 deletions

View File

@@ -15,7 +15,6 @@ const NOTIFICATION_WIDTH = 240
const NOTIFICATION_GAP = 16
function isWorkflowEditToolCall(name?: string, params?: Record<string, unknown>): boolean {
if (name === 'edit_workflow') return true
if (name !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''

View File

@@ -65,7 +65,6 @@ function isWorkflowChangeApplyMode(toolCall?: CopilotToolCall): boolean {
function isWorkflowEditSummaryTool(toolCall?: CopilotToolCall): boolean {
if (!toolCall) return false
if (toolCall.name === 'edit_workflow') return true
return isWorkflowChangeApplyMode(toolCall)
}
@@ -2209,7 +2208,7 @@ export function ToolCall({
</Button>
</div>
) : null}
{/* Workflow edit summary - shows block changes after edit_workflow/workflow_change(apply) */}
{/* Workflow edit summary - shows block changes after workflow_change(apply) */}
<WorkflowEditSummary toolCall={toolCall} />
{/* Render subagent content as thinking text */}

View File

@@ -3,6 +3,7 @@ import { STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
import { asRecord } from '@/lib/copilot/orchestrator/sse-utils'
import type { SSEEvent } from '@/lib/copilot/orchestrator/types'
import {
humanizedFallback,
isBackgroundState,
isRejectedState,
isReviewState,
@@ -27,7 +28,6 @@ const MIN_BATCH_INTERVAL = 16
const MAX_QUEUE_SIZE = 5
function isWorkflowEditToolCall(toolName?: string, params?: Record<string, unknown>): boolean {
if (toolName === 'edit_workflow') return true
if (toolName !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
@@ -109,6 +109,45 @@ function extractToolExecutionMetadata(
}
}
function displayVerb(state: ClientToolCallState): string {
switch (state) {
case ClientToolCallState.success:
return 'Completed'
case ClientToolCallState.error:
return 'Failed'
case ClientToolCallState.rejected:
return 'Skipped'
case ClientToolCallState.aborted:
return 'Aborted'
case ClientToolCallState.generating:
return 'Preparing'
case ClientToolCallState.pending:
return 'Waiting'
default:
return 'Running'
}
}
function resolveDisplayFromServerUi(
toolName: string,
state: ClientToolCallState,
toolCallId: string,
params: Record<string, unknown> | undefined,
ui?: CopilotToolCall['ui']
) {
const fallback =
resolveToolDisplay(toolName, state, toolCallId, params) ||
humanizedFallback(toolName, state)
if (!fallback) return undefined
if (ui?.phaseLabel) {
return { text: ui.phaseLabel, icon: fallback.icon }
}
if (ui?.title) {
return { text: `${displayVerb(state)} ${ui.title}`, icon: fallback.icon }
}
return fallback
}
function isWorkflowChangeApplyCall(toolName?: string, params?: Record<string, unknown>): boolean {
if (toolName !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
@@ -392,11 +431,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
execution: executionMetadata || current.execution,
params: paramsForCurrentToolCall,
state: targetState,
display: resolveToolDisplay(
display: resolveDisplayFromServerUi(
current.name,
targetState,
current.id,
paramsForCurrentToolCall
paramsForCurrentToolCall,
uiMetadata || current.ui
),
}
set({ toolCallsById: updatedMap })
@@ -483,7 +523,8 @@ export const sseHandlers: Record<string, SSEHandler> = {
(current.name === 'deploy_api' ||
current.name === 'deploy_chat' ||
current.name === 'deploy_mcp' ||
current.name === 'redeploy')
current.name === 'redeploy' ||
current.name === 'workflow_deploy')
) {
try {
const resultPayload = asRecord(
@@ -494,7 +535,19 @@ export const sseHandlers: Record<string, SSEHandler> = {
(resultPayload?.workflowId as string) ||
(input?.workflowId as string) ||
useWorkflowRegistry.getState().activeWorkflowId
const isDeployed = resultPayload?.isDeployed !== false
const deployMode = String(input?.mode || '')
const action = String(input?.action || 'deploy')
const isDeployed = (() => {
if (typeof resultPayload?.isDeployed === 'boolean') return resultPayload.isDeployed
if (current.name !== 'workflow_deploy') return true
if (deployMode === 'status') {
const statusIsDeployed = resultPayload?.isDeployed
return typeof statusIsDeployed === 'boolean' ? statusIsDeployed : true
}
if (deployMode === 'api' || deployMode === 'chat') return action !== 'undeploy'
if (deployMode === 'redeploy' || deployMode === 'mcp') return true
return true
})()
if (workflowId) {
useWorkflowRegistry
.getState()
@@ -608,11 +661,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
ui: uiMetadata || b.toolCall?.ui,
execution: executionMetadata || b.toolCall?.execution,
state: targetState,
display: resolveToolDisplay(
display: resolveDisplayFromServerUi(
b.toolCall?.name,
targetState,
toolCallId,
paramsForBlock
paramsForBlock,
uiMetadata || b.toolCall?.ui
),
},
}
@@ -658,7 +712,13 @@ export const sseHandlers: Record<string, SSEHandler> = {
ui: uiMetadata || current.ui,
execution: executionMetadata || current.execution,
state: targetState,
display: resolveToolDisplay(current.name, targetState, current.id, current.params),
display: resolveDisplayFromServerUi(
current.name,
targetState,
current.id,
current.params,
uiMetadata || current.ui
),
}
set({ toolCallsById: updatedMap })
}
@@ -685,11 +745,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
ui: uiMetadata || b.toolCall?.ui,
execution: executionMetadata || b.toolCall?.execution,
state: targetState,
display: resolveToolDisplay(
display: resolveDisplayFromServerUi(
b.toolCall?.name,
targetState,
toolCallId,
b.toolCall?.params
b.toolCall?.params,
uiMetadata || b.toolCall?.ui
),
},
}
@@ -718,13 +779,14 @@ export const sseHandlers: Record<string, SSEHandler> = {
if (!toolCallsById[toolCallId]) {
const initialState = ClientToolCallState.generating
const uiMetadata = extractToolUiMetadata(eventData)
const tc: CopilotToolCall = {
id: toolCallId,
name: toolName,
state: initialState,
ui: extractToolUiMetadata(eventData),
ui: uiMetadata,
execution: extractToolExecutionMetadata(eventData),
display: resolveToolDisplay(toolName, initialState, toolCallId),
display: resolveDisplayFromServerUi(toolName, initialState, toolCallId, undefined, uiMetadata),
}
const updated = { ...toolCallsById, [toolCallId]: tc }
set({ toolCallsById: updated })
@@ -774,7 +836,13 @@ export const sseHandlers: Record<string, SSEHandler> = {
ui: uiMetadata || existing.ui,
execution: executionMetadata || existing.execution,
...(args ? { params: args } : {}),
display: resolveToolDisplay(toolName, initialState, id, args || existing.params),
display: resolveDisplayFromServerUi(
toolName,
initialState,
id,
args || existing.params,
uiMetadata || existing.ui
),
}
: {
id,
@@ -783,7 +851,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
ui: uiMetadata,
execution: executionMetadata,
...(args ? { params: args } : {}),
display: resolveToolDisplay(toolName, initialState, id, args),
display: resolveDisplayFromServerUi(toolName, initialState, id, args, uiMetadata),
}
const updated = { ...toolCallsById, [id]: next }
set({ toolCallsById: updated })

View File

@@ -15,6 +15,7 @@ const logger = createLogger('CopilotRunToolExecution')
* (block pulsing, logs, stop button, etc.).
*/
export const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
'workflow_run',
'run_workflow',
'run_workflow_until_block',
'run_from_block',
@@ -74,14 +75,54 @@ async function doExecuteRunTool(
| Record<string, unknown>
| undefined
const runMode =
toolName === 'workflow_run'
? ((params.mode as string | undefined) || 'full').toLowerCase()
: undefined
if (
toolName === 'workflow_run' &&
runMode !== 'full' &&
runMode !== 'until_block' &&
runMode !== 'from_block' &&
runMode !== 'block'
) {
const error = `Unsupported workflow_run mode: ${String(params.mode)}`
logger.warn('[RunTool] Execution prevented: unsupported workflow_run mode', {
toolCallId,
mode: params.mode,
})
setToolState(toolCallId, ClientToolCallState.error)
await reportCompletion(toolCallId, false, error)
return
}
const stopAfterBlockId = (() => {
if (toolName === 'workflow_run' && runMode === 'until_block') {
return params.stopAfterBlockId as string | undefined
}
if (toolName === 'run_workflow_until_block')
return params.stopAfterBlockId as string | undefined
if (toolName === 'run_block') return params.blockId as string | undefined
if (toolName === 'workflow_run' && runMode === 'block') {
return params.blockId as string | undefined
}
return undefined
})()
const runFromBlock = (() => {
if (toolName === 'workflow_run' && runMode === 'from_block' && params.startBlockId) {
return {
startBlockId: params.startBlockId as string,
executionId: (params.executionId as string | undefined) || 'latest',
}
}
if (toolName === 'workflow_run' && runMode === 'block' && params.blockId) {
return {
startBlockId: params.blockId as string,
executionId: (params.executionId as string | undefined) || 'latest',
}
}
if (toolName === 'run_from_block' && params.startBlockId) {
return {
startBlockId: params.startBlockId as string,

View File

@@ -6,7 +6,7 @@ import {
shouldSkipToolResultEvent,
} from '@/lib/copilot/orchestrator/sse-utils'
import type { SSEEvent } from '@/lib/copilot/orchestrator/types'
import { resolveToolDisplay } from '@/lib/copilot/store-utils'
import { humanizedFallback, resolveToolDisplay } from '@/lib/copilot/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry'
import type { CopilotStore, CopilotToolCall } from '@/stores/panel/copilot/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -92,6 +92,45 @@ function extractToolExecutionMetadata(
}
}
function displayVerb(state: ClientToolCallState): string {
switch (state) {
case ClientToolCallState.success:
return 'Completed'
case ClientToolCallState.error:
return 'Failed'
case ClientToolCallState.rejected:
return 'Skipped'
case ClientToolCallState.aborted:
return 'Aborted'
case ClientToolCallState.generating:
return 'Preparing'
case ClientToolCallState.pending:
return 'Waiting'
default:
return 'Running'
}
}
function resolveDisplayFromServerUi(
toolName: string,
state: ClientToolCallState,
toolCallId: string,
params: Record<string, unknown> | undefined,
ui?: CopilotToolCall['ui']
) {
const fallback =
resolveToolDisplay(toolName, state, toolCallId, params) ||
humanizedFallback(toolName, state)
if (!fallback) return undefined
if (ui?.phaseLabel) {
return { text: ui.phaseLabel, icon: fallback.icon }
}
if (ui?.title) {
return { text: `${displayVerb(state)} ${ui.title}`, icon: fallback.icon }
}
return fallback
}
function isClientRunCapability(toolCall: CopilotToolCall): boolean {
if (toolCall.execution?.target === 'sim_client_capability') {
return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId
@@ -329,7 +368,7 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
ui: uiMetadata,
execution: executionMetadata,
...(args ? { params: args } : {}),
display: resolveToolDisplay(name, initialState, id, args),
display: resolveDisplayFromServerUi(name, initialState, id, args, uiMetadata),
}
if (existingIndex >= 0) {
@@ -421,7 +460,13 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
ui: uiMetadata || existing.ui,
execution: executionMetadata || existing.execution,
state: targetState,
display: resolveToolDisplay(existing.name, targetState, toolCallId, nextParams),
display: resolveDisplayFromServerUi(
existing.name,
targetState,
toolCallId,
nextParams,
uiMetadata || existing.ui
),
}
context.subAgentToolCalls[parentToolCallId][existingIndex] = updatedSubAgentToolCall

View File

@@ -29,6 +29,7 @@ const logger = createLogger('CopilotSseHandlers')
* execution to the browser client instead of running executeWorkflow directly.
*/
const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
'workflow_run',
'run_workflow',
'run_workflow_until_block',
'run_from_block',

View File

@@ -14,7 +14,7 @@ describe('sse-utils', () => {
type: 'tool_result',
data: JSON.stringify({
id: 'tool_1',
name: 'edit_workflow',
name: 'workflow_change',
success: true,
result: { ok: true },
}),
@@ -23,7 +23,7 @@ describe('sse-utils', () => {
const normalized = normalizeSseEvent(event as any)
expect(normalized.toolCallId).toBe('tool_1')
expect(normalized.toolName).toBe('edit_workflow')
expect(normalized.toolName).toBe('workflow_change')
expect(normalized.success).toBe(true)
expect(normalized.result).toEqual({ ok: true })
})

View File

@@ -220,7 +220,8 @@ export async function executeDeployMcp(
if (!workflowRecord.isDeployed) {
return {
success: false,
error: 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.',
error:
'Workflow must be deployed before adding as an MCP tool. Use workflow_deploy(mode: "api") first.',
}
}

View File

@@ -50,6 +50,8 @@ import type {
RunWorkflowParams,
RunWorkflowUntilBlockParams,
SetGlobalWorkflowVariablesParams,
WorkflowDeployParams,
WorkflowRunParams,
} from './param-types'
import { PLATFORM_ACTIONS_CONTENT } from './platform-actions'
import {
@@ -318,13 +320,87 @@ async function executeManageCustomTool(
}
}
async function executeWorkflowRunUnified(
rawParams: Record<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
const params = rawParams as WorkflowRunParams
const mode = params.mode || 'full'
switch (mode) {
case 'full':
return executeRunWorkflow(params as RunWorkflowParams, context)
case 'until_block':
if (!params.stopAfterBlockId) {
return { success: false, error: 'stopAfterBlockId is required for mode=until_block' }
}
return executeRunWorkflowUntilBlock(params as RunWorkflowUntilBlockParams, context)
case 'from_block':
if (!params.startBlockId) {
return { success: false, error: 'startBlockId is required for mode=from_block' }
}
return executeRunFromBlock(params as RunFromBlockParams, context)
case 'block':
if (!params.blockId) {
return { success: false, error: 'blockId is required for mode=block' }
}
return executeRunBlock(params as RunBlockParams, context)
default:
return {
success: false,
error: `Unsupported workflow_run mode: ${String(mode)}`,
}
}
}
async function executeWorkflowDeployUnified(
rawParams: Record<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
const params = rawParams as unknown as WorkflowDeployParams
const mode = params.mode
if (!mode) {
return { success: false, error: 'mode is required for workflow_deploy' }
}
const scopedContext =
params.workflowId && params.workflowId !== context.workflowId
? { ...context, workflowId: params.workflowId }
: context
switch (mode) {
case 'status':
return executeCheckDeploymentStatus(params as CheckDeploymentStatusParams, scopedContext)
case 'redeploy':
return executeRedeploy(scopedContext)
case 'api':
return executeDeployApi(params as DeployApiParams, scopedContext)
case 'chat':
return executeDeployChat(params as DeployChatParams, scopedContext)
case 'mcp':
return executeDeployMcp(params as DeployMcpParams, scopedContext)
case 'list_mcp_servers':
return executeListWorkspaceMcpServers(params as ListWorkspaceMcpServersParams, scopedContext)
case 'create_mcp_server':
return executeCreateWorkspaceMcpServer(
params as CreateWorkspaceMcpServerParams,
scopedContext
)
default:
return {
success: false,
error: `Unsupported workflow_deploy mode: ${String(mode)}`,
}
}
}
const SERVER_TOOLS = new Set<string>([
'get_blocks_and_tools',
'get_blocks_metadata',
'get_block_options',
'get_block_config',
'get_trigger_blocks',
'edit_workflow',
'workflow_context_get',
'workflow_context_expand',
'workflow_change',
@@ -356,6 +432,7 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
get_block_outputs: (p, c) => executeGetBlockOutputs(p as GetBlockOutputsParams, c),
get_block_upstream_references: (p, c) =>
executeGetBlockUpstreamReferences(p as unknown as GetBlockUpstreamReferencesParams, c),
workflow_run: (p, c) => executeWorkflowRunUnified(p, c),
run_workflow: (p, c) => executeRunWorkflow(p as RunWorkflowParams, c),
run_workflow_until_block: (p, c) =>
executeRunWorkflowUntilBlock(p as unknown as RunWorkflowUntilBlockParams, c),
@@ -371,6 +448,7 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record<
}),
set_global_workflow_variables: (p, c) =>
executeSetGlobalWorkflowVariables(p as SetGlobalWorkflowVariablesParams, c),
workflow_deploy: (p, c) => executeWorkflowDeployUnified(p, c),
deploy_api: (p, c) => executeDeployApi(p as DeployApiParams, c),
deploy_chat: (p, c) => executeDeployChat(p as DeployChatParams, c),
deploy_mcp: (p, c) => executeDeployMcp(p as DeployMcpParams, c),

View File

@@ -93,6 +93,18 @@ export interface RunBlockParams {
useDeployedState?: boolean
}
export interface WorkflowRunParams {
mode?: 'full' | 'until_block' | 'from_block' | 'block'
workflowId?: string
workflow_input?: unknown
input?: unknown
useDeployedState?: boolean
stopAfterBlockId?: string
startBlockId?: string
blockId?: string
executionId?: string
}
export interface GetDeployedWorkflowStateParams {
workflowId?: string
}
@@ -169,6 +181,39 @@ export interface CreateWorkspaceMcpServerParams {
workflowIds?: string[]
}
export interface WorkflowDeployParams {
mode:
| 'status'
| 'redeploy'
| 'api'
| 'chat'
| 'mcp'
| 'list_mcp_servers'
| 'create_mcp_server'
workflowId?: string
action?: 'deploy' | 'undeploy'
identifier?: string
title?: string
description?: string
customizations?: {
primaryColor?: string
secondaryColor?: string
welcomeMessage?: string
iconUrl?: string
}
authType?: 'none' | 'password' | 'public' | 'email' | 'sso'
password?: string
allowedEmails?: string[]
outputConfigs?: unknown[]
serverId?: string
toolName?: string
toolDescription?: string
parameterSchema?: Record<string, unknown>
name?: string
isPublic?: boolean
workflowIds?: string[]
}
// === Workflow Organization Params ===
export interface RenameWorkflowParams {

View File

@@ -592,23 +592,6 @@ const META_edit: ToolMetadata = {
},
}
const META_edit_workflow: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Editing your workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Edited your workflow', icon: Grid2x2Check },
[ClientToolCallState.error]: { text: 'Failed to edit your workflow', icon: XCircle },
[ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 },
[ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: Grid2x2X },
[ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
},
uiConfig: {
isSpecial: true,
customRenderer: 'edit_summary',
},
}
const META_workflow_change: ToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Planning workflow changes', icon: Loader2 },
@@ -2618,11 +2601,12 @@ const TOOL_METADATA_BY_ID: Record<string, ToolMetadata> = {
deploy_chat: META_deploy_chat,
deploy_mcp: META_deploy_mcp,
edit: META_edit,
edit_workflow: META_edit_workflow,
workflow_context_get: META_workflow_context_get,
workflow_context_expand: META_workflow_context_expand,
workflow_change: META_workflow_change,
workflow_verify: META_workflow_verify,
workflow_run: META_run_workflow,
workflow_deploy: META_deploy_api,
evaluate: META_evaluate,
get_block_config: META_get_block_config,
get_block_options: META_get_block_options,

View File

@@ -190,6 +190,52 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
required: ['folderId'],
},
},
{
name: 'workflow_run',
toolId: 'workflow_run',
description:
'Run a workflow using one unified interface. Supports full runs and partial execution modes.',
inputSchema: {
type: 'object',
properties: {
workflowId: {
type: 'string',
description: 'REQUIRED. The workflow ID to run.',
},
mode: {
type: 'string',
description: 'Execution mode: full, until_block, from_block, or block. Default: full.',
enum: ['full', 'until_block', 'from_block', 'block'],
},
workflow_input: {
type: 'object',
description:
'JSON object with input values. Keys should match workflow start block input names.',
},
stopAfterBlockId: {
type: 'string',
description: 'Required when mode is until_block.',
},
startBlockId: {
type: 'string',
description: 'Required when mode is from_block.',
},
blockId: {
type: 'string',
description: 'Required when mode is block.',
},
executionId: {
type: 'string',
description: 'Optional execution snapshot ID for from_block or block modes.',
},
useDeployedState: {
type: 'boolean',
description: 'When true, runs deployed state instead of draft. Default: false.',
},
},
required: ['workflowId'],
},
},
{
name: 'run_workflow',
toolId: 'run_workflow',
@@ -531,10 +577,10 @@ ALSO CAN:
description: `Run a workflow and verify its outputs. Works on both deployed and undeployed (draft) workflows. Use after building to verify correctness.
Supports full and partial execution:
- Full run with test inputs
- Stop after a specific block (run_workflow_until_block)
- Run a single block in isolation (run_block)
- Resume from a specific block (run_from_block)`,
- Full run with test inputs using workflow_run mode "full"
- Stop after a specific block using workflow_run mode "until_block"
- Run a single block in isolation using workflow_run mode "block"
- Resume from a specific block using workflow_run mode "from_block"`,
inputSchema: {
type: 'object',
properties: {

View File

@@ -109,7 +109,7 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
return undefined
}
// Return the actual option ID/value that edit_workflow expects, not the display label
// Return canonical option IDs/values expected by workflow_change compilation and apply
return rawOptions
.map((opt: any) => {
if (!opt) return undefined

View File

@@ -11,7 +11,6 @@ import { makeApiRequestServerTool } from '@/lib/copilot/tools/server/other/make-
import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables'
import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow'
import { getWorkflowConsoleServerTool } from '@/lib/copilot/tools/server/workflow/get-workflow-console'
import { workflowChangeServerTool } from '@/lib/copilot/tools/server/workflow/workflow-change'
import {
@@ -33,7 +32,6 @@ const serverToolRegistry: Record<string, BaseServerTool> = {
[getBlockOptionsServerTool.name]: getBlockOptionsServerTool,
[getBlockConfigServerTool.name]: getBlockConfigServerTool,
[getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool,
[editWorkflowServerTool.name]: editWorkflowServerTool,
[getWorkflowConsoleServerTool.name]: getWorkflowConsoleServerTool,
[searchDocumentationServerTool.name]: searchDocumentationServerTool,
[searchOnlineServerTool.name]: searchOnlineServerTool,

View File

@@ -71,6 +71,19 @@ export type WorkflowChangeProposal = {
warnings: string[]
diagnostics: string[]
touchedBlocks: string[]
acceptanceAssertions: string[]
postApply?: {
verify?: boolean
run?: Record<string, any>
evaluator?: Record<string, any>
}
handoff?: {
objective?: string
constraints?: string[]
resolvedIds?: Record<string, string>
assumptions?: string[]
unresolvedRisks?: string[]
}
}
const contextPackStore = new TTLStore<WorkflowContextPack>()

View File

@@ -68,8 +68,8 @@ async function getCurrentWorkflowStateFromDb(
return { workflowState, subBlockValues }
}
export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown> = {
name: 'edit_workflow',
export const applyWorkflowOperationsServerTool: BaseServerTool<EditWorkflowParams, unknown> = {
name: '__internal_apply_workflow_operations',
async execute(params: EditWorkflowParams, context?: { userId: string }): Promise<unknown> {
const logger = createLogger('EditWorkflowServerTool')
const { operations, workflowId, currentUserWorkflow } = params
@@ -90,7 +90,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
throw new Error(authorization.message || 'Unauthorized workflow access')
}
logger.info('Executing edit_workflow', {
logger.info('Executing internal workflow operation apply', {
operationCount: operations.length,
workflowId,
hasCurrentUserWorkflow: !!currentUserWorkflow,
@@ -210,7 +210,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
logger.warn('No userId in context - skipping custom tools persistence', { workflowId })
}
logger.info('edit_workflow successfully applied operations', {
logger.info('Internal workflow operation apply succeeded', {
operationCount: operations.length,
blocksCount: Object.keys(modifiedWorkflowState.blocks).length,
edgesCount: modifiedWorkflowState.edges.length,

View File

@@ -2,6 +2,13 @@ import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types'
import {
executeRunBlock,
executeRunFromBlock,
executeRunWorkflow,
executeRunWorkflowUntilBlock,
} from '@/lib/copilot/orchestrator/tool-executor/workflow-tools'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { getBlock } from '@/blocks/registry'
@@ -12,9 +19,10 @@ import {
saveProposal,
type WorkflowChangeProposal,
} from './change-store'
import { editWorkflowServerTool } from './edit-workflow'
import { applyWorkflowOperationsServerTool } from './edit-workflow'
import { applyOperationsToWorkflowState } from './edit-workflow/engine'
import { preValidateCredentialInputs } from './edit-workflow/validation'
import { workflowVerifyServerTool } from './workflow-verify'
import { hashWorkflowState, loadWorkflowStateFromDb } from './workflow-state'
const logger = createLogger('WorkflowChangeServerTool')
@@ -31,6 +39,9 @@ const TargetSchema = z
.optional(),
})
.strict()
.refine((target) => Boolean(target.blockId || target.alias || target.match), {
message: 'target must include blockId, alias, or match',
})
const CredentialSelectionSchema = z
.object({
@@ -51,33 +62,63 @@ const ChangeOperationSchema = z
})
.strict()
const MutationSchema = z
const EnsureBlockMutationSchema = z
.object({
action: z.enum([
'ensure_block',
'patch_block',
'remove_block',
'connect',
'disconnect',
'ensure_variable',
'set_variable',
]),
target: TargetSchema.optional(),
action: z.literal('ensure_block'),
target: TargetSchema,
type: z.string().optional(),
name: z.string().optional(),
inputs: z.record(z.any()).optional(),
triggerMode: z.boolean().optional(),
advancedMode: z.boolean().optional(),
enabled: z.boolean().optional(),
changes: z.array(ChangeOperationSchema).optional(),
from: TargetSchema.optional(),
to: TargetSchema.optional(),
})
.strict()
const PatchBlockMutationSchema = z
.object({
action: z.literal('patch_block'),
target: TargetSchema,
changes: z.array(ChangeOperationSchema).min(1),
})
.strict()
const RemoveBlockMutationSchema = z
.object({
action: z.literal('remove_block'),
target: TargetSchema,
})
.strict()
const ConnectMutationSchema = z
.object({
action: z.literal('connect'),
from: TargetSchema,
to: TargetSchema,
handle: z.string().optional(),
toHandle: z.string().optional(),
mode: z.enum(['set', 'append', 'remove']).optional(),
})
.strict()
const DisconnectMutationSchema = z
.object({
action: z.literal('disconnect'),
from: TargetSchema,
to: TargetSchema,
handle: z.string().optional(),
toHandle: z.string().optional(),
})
.strict()
const MutationSchema = z.discriminatedUnion('action', [
EnsureBlockMutationSchema,
PatchBlockMutationSchema,
RemoveBlockMutationSchema,
ConnectMutationSchema,
DisconnectMutationSchema,
])
const LinkEndpointSchema = z
.object({
blockId: z.string().optional(),
@@ -100,16 +141,64 @@ const LinkSchema = z
})
.strict()
const PostApplyRunSchema = z
.object({
enabled: z.boolean().optional(),
mode: z.enum(['full', 'until_block', 'from_block', 'block']).optional(),
useDeployedState: z.boolean().optional(),
workflowInput: z.record(z.any()).optional(),
stopAfterBlockId: z.string().optional(),
startBlockId: z.string().optional(),
blockId: z.string().optional(),
})
.strict()
const PostApplyEvaluatorSchema = z
.object({
enabled: z.boolean().optional(),
requireVerified: z.boolean().optional(),
maxWarnings: z.number().int().min(0).optional(),
maxDiagnostics: z.number().int().min(0).optional(),
requireRunSuccess: z.boolean().optional(),
})
.strict()
const PostApplySchema = z
.object({
verify: z.boolean().optional(),
run: PostApplyRunSchema.optional(),
evaluator: PostApplyEvaluatorSchema.optional(),
})
.strict()
const AcceptanceItemSchema = z.union([
z.string(),
z
.object({
kind: z.string().optional(),
assert: z.string(),
})
.strict(),
])
const ChangeSpecSchema = z
.object({
version: z.literal('1').optional(),
objective: z.string().optional(),
constraints: z.record(z.any()).optional(),
constraints: z.array(z.string()).optional(),
assumptions: z.array(z.string()).optional(),
unresolvedRisks: z.array(z.string()).optional(),
resolvedIds: z.record(z.string()).optional(),
resources: z.record(z.any()).optional(),
mutations: z.array(MutationSchema).optional(),
links: z.array(LinkSchema).optional(),
acceptance: z.array(z.any()).optional(),
acceptance: z.array(AcceptanceItemSchema).optional(),
postApply: PostApplySchema.optional(),
})
.strict()
.refine((spec) => Boolean((spec.mutations && spec.mutations.length > 0) || (spec.links && spec.links.length > 0)), {
message: 'changeSpec must include at least one mutation or link',
})
const WorkflowChangeInputSchema = z
.object({
@@ -120,13 +209,69 @@ const WorkflowChangeInputSchema = z
baseSnapshotHash: z.string().optional(),
expectedSnapshotHash: z.string().optional(),
changeSpec: ChangeSpecSchema.optional(),
postApply: PostApplySchema.optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.mode === 'dry_run') {
if (!value.workflowId && !value.contextPackId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['workflowId'],
message: 'workflowId is required for dry_run when contextPackId is not provided',
})
}
if (!value.changeSpec) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['changeSpec'],
message: 'changeSpec is required for dry_run',
})
}
return
}
if (!value.proposalId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['proposalId'],
message: 'proposalId is required for apply',
})
}
if (!value.expectedSnapshotHash) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['expectedSnapshotHash'],
message: 'expectedSnapshotHash is required for apply',
})
}
})
type WorkflowChangeParams = z.input<typeof WorkflowChangeInputSchema>
type ChangeSpec = z.input<typeof ChangeSpecSchema>
type TargetRef = z.input<typeof TargetSchema>
type ChangeOperation = z.input<typeof ChangeOperationSchema>
type PostApply = z.input<typeof PostApplySchema>
type NormalizedPostApply = {
verify: boolean
run: {
enabled: boolean
mode: 'full' | 'until_block' | 'from_block' | 'block'
useDeployedState: boolean
workflowInput?: Record<string, any>
stopAfterBlockId?: string
startBlockId?: string
blockId?: string
}
evaluator: {
enabled: boolean
requireVerified: boolean
maxWarnings: number
maxDiagnostics: number
requireRunSuccess: boolean
}
}
type CredentialRecord = {
id: string
@@ -162,6 +307,173 @@ function stableUnique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))]
}
function normalizeAcceptance(assertions: ChangeSpec['acceptance'] | undefined): string[] {
if (!Array.isArray(assertions)) return []
return assertions
.map((item) => (typeof item === 'string' ? item : item?.assert))
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
}
function normalizePostApply(postApply?: PostApply): NormalizedPostApply {
const run = postApply?.run
const evaluator = postApply?.evaluator
return {
verify: postApply?.verify !== false,
run: {
enabled: run?.enabled === true,
mode: run?.mode || 'full',
useDeployedState: run?.useDeployedState === true,
workflowInput:
run?.workflowInput && typeof run.workflowInput === 'object'
? (run.workflowInput as Record<string, any>)
: undefined,
stopAfterBlockId: run?.stopAfterBlockId,
startBlockId: run?.startBlockId,
blockId: run?.blockId,
},
evaluator: {
enabled: evaluator?.enabled !== false,
requireVerified: evaluator?.requireVerified !== false,
maxWarnings:
typeof evaluator?.maxWarnings === 'number' && evaluator.maxWarnings >= 0
? evaluator.maxWarnings
: 50,
maxDiagnostics:
typeof evaluator?.maxDiagnostics === 'number' && evaluator.maxDiagnostics >= 0
? evaluator.maxDiagnostics
: 0,
requireRunSuccess: evaluator?.requireRunSuccess === true,
},
}
}
async function executePostApplyRun(params: {
workflowId: string
userId: string
run: NormalizedPostApply['run']
}): Promise<ToolCallResult> {
const context: ExecutionContext = {
userId: params.userId,
workflowId: params.workflowId,
}
switch (params.run.mode) {
case 'until_block': {
if (!params.run.stopAfterBlockId) {
return {
success: false,
error: 'postApply.run.stopAfterBlockId is required for mode "until_block"',
}
}
return executeRunWorkflowUntilBlock(
{
workflowId: params.workflowId,
stopAfterBlockId: params.run.stopAfterBlockId,
useDeployedState: params.run.useDeployedState,
workflow_input: params.run.workflowInput,
},
context
)
}
case 'from_block': {
if (!params.run.startBlockId) {
return {
success: false,
error: 'postApply.run.startBlockId is required for mode "from_block"',
}
}
return executeRunFromBlock(
{
workflowId: params.workflowId,
startBlockId: params.run.startBlockId,
useDeployedState: params.run.useDeployedState,
workflow_input: params.run.workflowInput,
},
context
)
}
case 'block': {
if (!params.run.blockId) {
return {
success: false,
error: 'postApply.run.blockId is required for mode "block"',
}
}
return executeRunBlock(
{
workflowId: params.workflowId,
blockId: params.run.blockId,
useDeployedState: params.run.useDeployedState,
workflow_input: params.run.workflowInput,
},
context
)
}
default:
return executeRunWorkflow(
{
workflowId: params.workflowId,
useDeployedState: params.run.useDeployedState,
workflow_input: params.run.workflowInput,
},
context
)
}
}
function evaluatePostApplyGate(params: {
verifyEnabled: boolean
verifyResult: any | null
runEnabled: boolean
runResult: ToolCallResult | null
evaluator: NormalizedPostApply['evaluator']
warnings: string[]
diagnostics: string[]
}): {
passed: boolean
reasons: string[]
summary: string
} {
if (!params.evaluator.enabled) {
return {
passed: true,
reasons: [],
summary: 'Evaluator gate disabled',
}
}
const reasons: string[] = []
if (params.verifyEnabled && params.evaluator.requireVerified) {
const verified = params.verifyResult?.verified === true
if (!verified) {
reasons.push('verification_failed')
}
}
if (params.warnings.length > params.evaluator.maxWarnings) {
reasons.push(`warnings_exceeded:${params.warnings.length}`)
}
if (params.diagnostics.length > params.evaluator.maxDiagnostics) {
reasons.push(`diagnostics_exceeded:${params.diagnostics.length}`)
}
if (params.runEnabled && params.evaluator.requireRunSuccess) {
if (!params.runResult || params.runResult.success !== true) {
reasons.push('run_failed')
}
}
const passed = reasons.length === 0
return {
passed,
reasons,
summary: passed ? 'Evaluator gate passed' : `Evaluator gate failed: ${reasons.join(', ')}`,
}
}
function buildConnectionState(workflowState: {
edges: Array<Record<string, any>>
}): ConnectionState {
@@ -679,7 +991,8 @@ async function compileChangeSpec(params: {
connectionState.set(from, sourceMap)
}
const existingTargets = sourceMap.get(sourceHandle) || []
const mode = mutation.action === 'disconnect' ? 'remove' : mutation.mode || 'set'
const mode =
mutation.action === 'disconnect' ? 'remove' : ('mode' in mutation ? mutation.mode : undefined) || 'set'
const nextTargets = ensureConnectionTarget(
existingTargets,
{ block: to, handle: targetHandle },
@@ -895,6 +1208,10 @@ export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any>
)
const diagnostics = [...compileResult.diagnostics, ...simulation.diagnostics]
const warnings = [...compileResult.warnings, ...simulation.warnings]
const acceptanceAssertions = normalizeAcceptance(params.changeSpec.acceptance)
const normalizedPostApply = normalizePostApply(
(params.postApply as PostApply | undefined) || params.changeSpec.postApply
)
const proposal: WorkflowChangeProposal = {
workflowId,
@@ -904,6 +1221,15 @@ export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any>
warnings,
diagnostics,
touchedBlocks: compileResult.touchedBlocks,
acceptanceAssertions,
postApply: normalizedPostApply,
handoff: {
objective: params.changeSpec.objective,
constraints: params.changeSpec.constraints,
resolvedIds: params.changeSpec.resolvedIds,
assumptions: params.changeSpec.assumptions,
unresolvedRisks: params.changeSpec.unresolvedRisks,
},
}
const proposalId = saveProposal(proposal)
@@ -913,6 +1239,7 @@ export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any>
operationCount: proposal.compiledOperations.length,
warningCount: warnings.length,
diagnosticsCount: diagnostics.length,
acceptanceCount: acceptanceAssertions.length,
})
return {
@@ -926,6 +1253,9 @@ export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any>
warnings,
diagnostics,
touchedBlocks: proposal.touchedBlocks,
acceptance: proposal.acceptanceAssertions,
postApply: normalizedPostApply,
handoff: proposal.handoff,
}
}
@@ -951,12 +1281,17 @@ export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any>
const { workflowState } = await loadWorkflowStateFromDb(proposal.workflowId)
const currentHash = hashWorkflowState(workflowState as unknown as Record<string, unknown>)
const expectedHash = params.expectedSnapshotHash || proposal.baseSnapshotHash
if (expectedHash && expectedHash !== currentHash) {
const expectedHash = params.expectedSnapshotHash
if (expectedHash !== proposal.baseSnapshotHash) {
throw new Error(
`snapshot_mismatch: expectedSnapshotHash ${expectedHash} does not match proposal base ${proposal.baseSnapshotHash}`
)
}
if (expectedHash !== currentHash) {
throw new Error(`snapshot_mismatch: expected ${expectedHash} but current is ${currentHash}`)
}
const applyResult = await editWorkflowServerTool.execute(
const applyResult = await applyWorkflowOperationsServerTool.execute(
{
workflowId: proposal.workflowId,
operations: proposal.compiledOperations as any,
@@ -968,9 +1303,43 @@ export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any>
const newSnapshotHash = appliedWorkflowState
? hashWorkflowState(appliedWorkflowState as Record<string, unknown>)
: null
const normalizedPostApply = normalizePostApply(
(params.postApply as PostApply | undefined) || (proposal.postApply as PostApply | undefined)
)
let verifyResult: any | null = null
if (normalizedPostApply.verify) {
verifyResult = await workflowVerifyServerTool.execute(
{
workflowId: proposal.workflowId,
baseSnapshotHash: newSnapshotHash || undefined,
acceptance: proposal.acceptanceAssertions,
},
{ userId: context.userId }
)
}
let runResult: ToolCallResult | null = null
if (normalizedPostApply.run.enabled) {
runResult = await executePostApplyRun({
workflowId: proposal.workflowId,
userId: context.userId,
run: normalizedPostApply.run,
})
}
const evaluatorGate = evaluatePostApplyGate({
verifyEnabled: normalizedPostApply.verify,
verifyResult,
runEnabled: normalizedPostApply.run.enabled,
runResult,
evaluator: normalizedPostApply.evaluator,
warnings: proposal.warnings,
diagnostics: proposal.diagnostics,
})
return {
success: true,
success: evaluatorGate.passed,
mode: 'apply',
workflowId: proposal.workflowId,
proposalId,
@@ -982,6 +1351,14 @@ export const workflowChangeServerTool: BaseServerTool<WorkflowChangeParams, any>
warnings: proposal.warnings,
diagnostics: proposal.diagnostics,
editResult: applyResult,
postApply: {
ok: evaluatorGate.passed,
policy: normalizedPostApply,
verify: verifyResult,
run: runResult,
evaluator: evaluatorGate,
},
handoff: proposal.handoff,
}
},
}

View File

@@ -18,6 +18,7 @@ const WorkflowContextGetInputSchema = z.object({
objective: z.string().optional(),
includeBlockTypes: z.array(z.string()).optional(),
includeAllSchemas: z.boolean().optional(),
schemaMode: z.enum(['minimal', 'workflow', 'all']).optional(),
})
type WorkflowContextGetParams = z.infer<typeof WorkflowContextGetInputSchema>
@@ -71,11 +72,16 @@ export const workflowContextGetServerTool: BaseServerTool<WorkflowContextGetPara
String(block?.type || '')
)
const requestedTypes = params.includeBlockTypes || []
const includeAllSchemas = params.includeAllSchemas === true
const candidateTypes = includeAllSchemas
? getAllKnownBlockTypes()
: [...blockTypesInWorkflow, ...requestedTypes]
const schemaMode =
params.includeAllSchemas === true ? 'all' : (params.schemaMode || 'minimal')
const candidateTypes =
schemaMode === 'all'
? getAllKnownBlockTypes()
: schemaMode === 'workflow'
? [...blockTypesInWorkflow, ...requestedTypes]
: [...requestedTypes]
const { schemasByType, schemaRefsByType } = buildSchemasByType(candidateTypes)
const suggestedSchemaTypes = [...new Set(blockTypesInWorkflow.filter(Boolean))]
const summary = summarizeWorkflowState(workflowState)
const packId = saveContextPack({
@@ -101,12 +107,14 @@ export const workflowContextGetServerTool: BaseServerTool<WorkflowContextGetPara
contextPackId: packId,
workflowId: params.workflowId,
snapshotHash,
schemaMode,
summary: {
...summary,
objective: params.objective || null,
},
schemaRefsByType,
availableBlockCatalog: buildAvailableBlockCatalog(schemaRefsByType),
suggestedSchemaTypes,
inScopeSchemas: schemasByType,
}
},

View File

@@ -84,7 +84,6 @@ function isPageUnloading(): boolean {
}
function isWorkflowEditToolCall(name?: string, params?: Record<string, unknown>): boolean {
if (name === 'edit_workflow') return true
if (name !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''

View File

@@ -130,13 +130,12 @@ export async function findLatestEditWorkflowToolCallId(): Promise<string | undef
}
function isWorkflowEditToolCall(name?: string, params?: Record<string, unknown>): boolean {
if (name === 'edit_workflow') return true
if (name !== 'workflow_change') return false
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
if (mode === 'apply') return true
// Be permissive for legacy/incomplete events: apply calls always include proposalId.
// Be permissive for incomplete events: apply calls always include proposalId.
return typeof params?.proposalId === 'string' && params.proposalId.length > 0
}