fix(mothership): fix workflow vfs reads (#4156)

* v0.6.29: login improvements, posthog telemetry (#4026)

* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(login): fix captcha headers for manual login  (#4025)

* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha

* fix build error

* improvement(mothership): new agent loop (#3920)

* feat(transport): replace shared chat transport with mothership-stream module

* improvement(contracts): regenerate contracts from go

* feat(tools): add tool catalog codegen from go tool contracts

* feat(tools): add tool-executor dispatch framework for sim side tool routing

* feat(orchestrator): rewrite tool dispatch with catalog-driven executor and simplified resume loop

* feat(orchestrator): checkpoint resume flow

* refactor(copilot): consolidate orchestrator into request/ layer

* refactor(mothership): reorganize lib/copilot into structured subdirectories

* refactor(mothership): canonical transcript layer, dead code cleanup, type consolidation

* refactor(mothership): rebase onto latest staging

* refactor(mothership): rename request continue to lifecycle

* feat(trace): add initial version of request traces

* improvement(stream): batch stream from redis

* fix(resume): fix the resume checkpoint

* fix(resume): fix resume client tool

* fix(subagents): subagent resume should join on existing subagent text block

* improvement(reconnect): harden reconnect logic

* fix(superagent): fix superagent integration tools

* improvement(stream): improve stream perf

* Rebase with origin dev

* fix(tests): fix failing test

* fix(build): fix type errors

* fix(build): fix build errors

* fix(build): fix type errors

* feat(mothership): add cli execution

* fix(mothership): fix function execute tests

* Force redeploy

* feat(motheship): add docx support

* feat(mothership): append

* Add deps

* improvement(mothership): docs

* File types

* Add client retry logic

* Fix stream reconnect

* Eager tool streaming

* Fix client side tools

* Security

* Fix shell var injection

* Remove auto injected tasks

* Fix 10mb tool response limit

* Fix trailing leak

* Remove dead tools

* file/folder tools

* Folder tools

* Hide function code inline

* Dont show internal tool result reads

* Fix spacing

* Auth vfs

* Empty folders should show in vfs

* Fix run workflow

* change to node runtime

* revert back to bun runtime

* Fix

* Appends

* Remove debug logs

* Patch

* Fix patch tool

* Temp

* Checkpoint

* File writes

* Fix

* Remove tool truncation limits

* Bad hook

* replace react markdown with streamdown

* Checkpoitn

* fix code block

* fix stream persistence

* temp

* Fix file tools

* tool joining

* cleanup subagent + streaming issues

* streamed text change

* Tool display intetns

* Fix dev

* Fix tests

* Fix dev

* Speed up dev ci

* Add req id

* Fix persistence

* Tool call names

* fix payload accesses

* Fix name

* fix snapshot crash bug

* fix

* Fix

* remove worker code

* Clickable resources

* Options ordering

* Folder vfs

* Restore and mass delete tools

* Fix

* lint

* Update request tracing and skills and handlers

* Fix editable

* fix type error

* Html code

* fix(chat): make inline code inherit parent font size in markdown headers

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

* improved autolayout

* durable stream for files

* one more fix

* POSSIBLE BREAKAGE: SCROLLING

* Fixes

* Fixes

* Lint fix

* fix(resource): fix resource view disappearing on ats (#4103)

Co-authored-by: Theodore Li <theo@sim.ai>

* Fixes

* feat(mothership): add execution logs as a resource type

Adds `log` as a first-class mothership resource type so copilot can open
and display workflow execution logs as tabs alongside workflows, tables,
files, and knowledge bases.

- Add `log` to MothershipResourceType, all Zod enums, and VALID_RESOURCE_TYPES
- Register log in RESOURCE_REGISTRY (Library icon) and RESOURCE_INVALIDATORS
- Add EmbeddedLog and EmbeddedLogActions components in resource-content
- Export WorkflowOutputSection from log-details for reuse in EmbeddedLog
- Add log resolution branch in open_resource handler via new getLogById service
- Include log id in get_workflow_logs response and extract resources from output
- Exclude log from manual add-resource dropdown (enters via copilot tools only)
- Regenerate copilot contracts after adding log to open_resource Go enum

* Fix perf and message queueing

* Fix abort

* fix(ui): dont delete resource on clearing from context, set resource closed on new task (#4113)

Co-authored-by: Theodore Li <theo@sim.ai>

* improvement(mothership): structure sim side typing

* address comments

* reactive text editor tweaks

* Fix file read and tool call name persistence bug

* Fix code stream + create file opening resource

* fix use chat race + headless trace issues

* Fix type issue

* Fix mothership block req lifecycle

* Fix build

* Move copy reqid

* Fix

* fix(ui): fix resource tag transition from home to task (#4132)

Co-authored-by: Theodore Li <theo@sim.ai>

* Fix persistence

* Clean code, fix bugs

* Fix

* Fixes

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Siddharth Ganesan
2026-04-14 12:11:53 -07:00
committed by GitHub
co-authored by Theodore Li Waleed Theodore Li Vikhyath Mondreti Claude Opus 4.6
parent 64cdab24f7
commit ff2e369c20
16 changed files with 293 additions and 57 deletions
+10 -3
View File
@@ -688,11 +688,16 @@ async function handleBuildToolCall(
userId,
action: 'read',
})
return authorization.allowed ? { workflowId } : null
return authorization.allowed
? { status: 'resolved' as const, workflowId }
: {
status: 'not_found' as const,
message: 'workflowId is required for build. Call create_workflow first.',
}
})()
: await resolveWorkflowIdForUser(userId)
if (!resolved?.workflowId) {
if (!resolved || resolved.status !== 'resolved') {
return {
content: [
{
@@ -700,7 +705,9 @@ async function handleBuildToolCall(
text: JSON.stringify(
{
success: false,
error: 'workflowId is required for build. Call create_workflow first.',
error:
resolved?.message ??
'workflowId is required for build. Call create_workflow first.',
},
null,
2
+4 -4
View File
@@ -29,8 +29,8 @@ const RequestSchema = z.object({
*
* workflowId is optional - if not provided:
* - If workflowName is provided, finds that workflow
* - Otherwise uses the user's first workflow as context
* - The copilot can still operate on any workflow using list_user_workflows
* - If exactly one workflow is available, uses that workflow as context
* - Otherwise requires workflowId or workflowName to disambiguate
*/
export async function POST(req: NextRequest) {
let messageId: string | undefined
@@ -54,11 +54,11 @@ export async function POST(req: NextRequest) {
parsed.workflowName,
auth.keyType === 'workspace' ? auth.workspaceId : undefined
)
if (!resolved) {
if (resolved.status !== 'resolved') {
return NextResponse.json(
{
success: false,
error: 'No workflows found. Create a workflow first or provide a valid workflowId.',
error: resolved.message,
},
{ status: 400 }
)
@@ -21,7 +21,13 @@ interface AgentGroupProps {
}
function isToolDone(status: ToolCallData['status']): boolean {
return status === 'success' || status === 'error' || status === 'cancelled'
return (
status === 'success' ||
status === 'error' ||
status === 'cancelled' ||
status === 'skipped' ||
status === 'rejected'
)
}
export function AgentGroup({
@@ -70,7 +70,13 @@ function resolveAgentLabel(key: string): string {
}
function isToolDone(status: ToolCallData['status']): boolean {
return status === 'success' || status === 'error' || status === 'cancelled'
return (
status === 'success' ||
status === 'error' ||
status === 'cancelled' ||
status === 'skipped' ||
status === 'rejected'
)
}
function isDelegatingTool(tc: NonNullable<ContentBlock['toolCall']>): boolean {
@@ -87,6 +93,10 @@ function mapToolStatusToClientState(
return ClientToolCallState.error
case 'cancelled':
return ClientToolCallState.cancelled
case 'skipped':
return ClientToolCallState.aborted
case 'rejected':
return ClientToolCallState.rejected
default:
return ClientToolCallState.executing
}
@@ -41,6 +41,12 @@ export function GenericResourceContent({ data }: GenericResourceContentProps) {
{entry.status === 'error' && (
<span className='ml-auto text-[12px] text-[var(--text-error)]'>Error</span>
)}
{entry.status === 'skipped' && (
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>Skipped</span>
)}
{entry.status === 'rejected' && (
<span className='ml-auto text-[12px] text-[var(--text-muted)]'>Rejected</span>
)}
</div>
{entry.streamingArgs && (
<pre className='overflow-x-auto whitespace-pre-wrap break-words font-mono text-[12px] text-[var(--text-body)]'>
@@ -119,6 +119,7 @@ import type {
MothershipResourceType,
QueuedMessage,
} from '../types'
import { ToolCallStatus } from '../types'
const FILE_SUBAGENT_ID = 'file'
@@ -610,6 +611,28 @@ function getToolUI(ui?: MothershipStreamV1ToolUI): StreamToolUI | undefined {
}
}
function resolveLiveToolStatus(
payload: Partial<{
status: string
success: boolean
}>
): ToolCallStatus {
switch (payload.status) {
case MothershipStreamV1ToolOutcome.success:
return ToolCallStatus.success
case MothershipStreamV1ToolOutcome.error:
return ToolCallStatus.error
case MothershipStreamV1ToolOutcome.cancelled:
return ToolCallStatus.cancelled
case MothershipStreamV1ToolOutcome.skipped:
return ToolCallStatus.skipped
case MothershipStreamV1ToolOutcome.rejected:
return ToolCallStatus.rejected
default:
return payload.success === true ? ToolCallStatus.success : ToolCallStatus.error
}
}
/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */
function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean {
const workflows = getWorkflows(workspaceId)
@@ -1396,6 +1419,7 @@ export function useChat(
let activeSubagent: string | undefined
let activeSubagentParentToolCallId: string | undefined
let activeCompactionId: string | undefined
const subagentByParentToolCallId = new Map<string, string>()
if (preserveState) {
for (let i = blocks.length - 1; i >= 0; i--) {
@@ -1418,20 +1442,32 @@ export function useChat(
streamingBlocksRef.current = []
}
const ensureTextBlock = (): ContentBlock => {
const ensureTextBlock = (subagentName?: string): ContentBlock => {
const last = blocks[blocks.length - 1]
if (last?.type === 'text' && last.subagent === activeSubagent) return last
if (last?.type === 'text' && last.subagent === subagentName) return last
const b: ContentBlock = { type: 'text', content: '' }
if (subagentName) b.subagent = subagentName
blocks.push(b)
return b
}
const appendInlineErrorTag = (tag: string) => {
const resolveScopedSubagent = (
agentId: string | undefined,
parentToolCallId: string | undefined
): string | undefined => {
if (agentId) return agentId
if (parentToolCallId) {
const scoped = subagentByParentToolCallId.get(parentToolCallId)
if (scoped) return scoped
}
return activeSubagent
}
const appendInlineErrorTag = (tag: string, subagentName?: string) => {
if (runningText.includes(tag)) return
const tb = ensureTextBlock()
const tb = ensureTextBlock(subagentName)
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
tb.content = `${tb.content ?? ''}${prefix}${tag}`
if (activeSubagent) tb.subagent = activeSubagent
runningText += `${prefix}${tag}`
streamingContentRef.current = runningText
flush()
@@ -1545,6 +1581,13 @@ export function useChat(
}
logger.debug('SSE event received', parsed)
const scopedParentToolCallId =
typeof parsed.scope?.parentToolCallId === 'string'
? parsed.scope.parentToolCallId
: undefined
const scopedAgentId =
typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined
const scopedSubagent = resolveScopedSubagent(scopedAgentId, scopedParentToolCallId)
switch (parsed.type) {
case MothershipStreamV1EventType.session: {
const payload = parsed.payload
@@ -1600,16 +1643,15 @@ export function useChat(
case MothershipStreamV1EventType.text: {
const chunk = parsed.payload.text
if (chunk) {
const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main'
const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main'
const needsBoundaryNewline =
lastContentSource !== null &&
lastContentSource !== contentSource &&
runningText.length > 0 &&
!runningText.endsWith('\n')
const tb = ensureTextBlock()
const tb = ensureTextBlock(scopedSubagent)
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
tb.content = (tb.content ?? '') + normalizedChunk
if (activeSubagent) tb.subagent = activeSubagent
runningText += normalizedChunk
lastContentSource = contentSource
streamingContentRef.current = runningText
@@ -1800,22 +1842,24 @@ export function useChat(
}
const tc = blocks[idx].toolCall!
const outputObj = asPayloadRecord(payload.output)
const success =
payload.success ?? payload.status === MothershipStreamV1ToolOutcome.success
const isCancelled =
outputObj?.reason === 'user_cancelled' ||
outputObj?.cancelledByUser === true ||
payload.status === MothershipStreamV1ToolOutcome.cancelled
const status = isCancelled
? ToolCallStatus.cancelled
: resolveLiveToolStatus(payload)
const isSuccess = status === ToolCallStatus.success
if (isCancelled) {
tc.status = 'cancelled'
if (status === ToolCallStatus.cancelled) {
tc.status = ToolCallStatus.cancelled
tc.displayTitle = 'Stopped by user'
} else {
tc.status = success ? 'success' : 'error'
tc.status = status
}
tc.streamingArgs = undefined
tc.result = {
success: !!success,
success: isSuccess,
output: payload.output,
error: typeof payload.error === 'string' ? payload.error : undefined,
}
@@ -1902,7 +1946,7 @@ export function useChat(
})
setActiveResourceId(fileResource.id)
invalidateResourceQueries(queryClient, workspaceId, 'file', fileResource.id)
} else if (!activeSubagent || activeSubagent !== FILE_SUBAGENT_ID) {
} else if (tc.calledBy !== FILE_SUBAGENT_ID) {
setResources((rs) => rs.filter((r) => r.id !== 'streaming-file'))
}
}
@@ -1948,7 +1992,7 @@ export function useChat(
status: 'executing',
displayTitle,
params: args,
calledBy: activeSubagent,
calledBy: scopedSubagent,
},
})
if (name === ReadTool.id || isResourceToolName(name)) {
@@ -2064,23 +2108,18 @@ export function useChat(
}
const spanData = asPayloadRecord(payload.data)
const parentToolCallId =
typeof parsed.scope?.parentToolCallId === 'string'
? parsed.scope.parentToolCallId
: typeof spanData?.tool_call_id === 'string'
? spanData.tool_call_id
: undefined
scopedParentToolCallId ??
(typeof spanData?.tool_call_id === 'string' ? spanData.tool_call_id : undefined)
const isPendingPause = spanData?.pending === true
const name =
typeof payload.agent === 'string'
? payload.agent
: typeof parsed.scope?.agentId === 'string'
? parsed.scope.agentId
: undefined
const name = typeof payload.agent === 'string' ? payload.agent : scopedAgentId
if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) {
const isSameActiveSubagent =
activeSubagent === name &&
activeSubagentParentToolCallId &&
parentToolCallId === activeSubagentParentToolCallId
if (parentToolCallId) {
subagentByParentToolCallId.set(parentToolCallId, name)
}
activeSubagent = name
activeSubagentParentToolCallId = parentToolCallId
if (!isSameActiveSubagent) {
@@ -2104,6 +2143,9 @@ export function useChat(
if (isPendingPause) {
break
}
if (parentToolCallId) {
subagentByParentToolCallId.delete(parentToolCallId)
}
if (previewSessionRef.current && !activePreviewSessionIdRef.current) {
const lastFileResource = resourcesRef.current.find(
(r) => r.type === 'file' && r.id !== 'streaming-file'
@@ -2113,8 +2155,14 @@ export function useChat(
setActiveResourceId(lastFileResource.id)
}
}
activeSubagent = undefined
activeSubagentParentToolCallId = undefined
if (
!parentToolCallId ||
parentToolCallId === activeSubagentParentToolCallId ||
name === activeSubagent
) {
activeSubagent = undefined
activeSubagentParentToolCallId = undefined
}
blocks.push({ type: 'subagent_end' })
flush()
}
@@ -2123,7 +2171,7 @@ export function useChat(
case MothershipStreamV1EventType.error: {
sawStreamError = true
setError(parsed.payload.message || parsed.payload.error || 'An error occurred')
appendInlineErrorTag(buildInlineErrorTag(parsed.payload))
appendInlineErrorTag(buildInlineErrorTag(parsed.payload), scopedSubagent)
break
}
case MothershipStreamV1EventType.complete: {
@@ -59,6 +59,8 @@ export const ToolCallStatus = {
success: 'success',
error: 'error',
cancelled: 'cancelled',
skipped: 'skipped',
rejected: 'rejected',
} as const
export type ToolCallStatus = (typeof ToolCallStatus)[keyof typeof ToolCallStatus]
@@ -87,4 +87,62 @@ describe('display-message', () => {
expect(display.contentBlocks).toEqual([{ type: 'text', content: 'visible text' }])
})
it('preserves skipped and rejected tool outcomes', () => {
const display = toDisplayMessage({
id: 'msg-3',
role: 'assistant',
content: '',
timestamp: '2024-01-01T00:00:00.000Z',
contentBlocks: [
{
type: 'tool',
phase: 'call',
toolCall: {
id: 'tool-skipped',
name: 'read',
state: 'skipped',
display: { title: 'Reading workflow' },
},
},
{
type: 'tool',
phase: 'call',
toolCall: {
id: 'tool-rejected',
name: 'run_workflow',
state: 'rejected',
display: { title: 'Running workflow' },
},
},
],
})
expect(display.contentBlocks).toEqual([
{
type: 'tool_call',
toolCall: {
id: 'tool-skipped',
name: 'read',
status: 'skipped',
displayTitle: 'Reading workflow',
params: undefined,
calledBy: undefined,
result: undefined,
},
},
{
type: 'tool_call',
toolCall: {
id: 'tool-rejected',
name: 'run_workflow',
status: 'rejected',
displayTitle: 'Running workflow',
params: undefined,
calledBy: undefined,
result: undefined,
},
},
])
})
})
+2 -2
View File
@@ -21,8 +21,8 @@ const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
[MothershipStreamV1ToolOutcome.success]: ToolCallStatus.success,
[MothershipStreamV1ToolOutcome.error]: ToolCallStatus.error,
[MothershipStreamV1ToolOutcome.cancelled]: ToolCallStatus.cancelled,
[MothershipStreamV1ToolOutcome.rejected]: ToolCallStatus.error,
[MothershipStreamV1ToolOutcome.skipped]: ToolCallStatus.success,
[MothershipStreamV1ToolOutcome.rejected]: ToolCallStatus.rejected,
[MothershipStreamV1ToolOutcome.skipped]: ToolCallStatus.skipped,
pending: ToolCallStatus.executing,
executing: ToolCallStatus.executing,
}
+1
View File
@@ -121,6 +121,7 @@ describe('handleUnifiedChatPost', () => {
vi.clearAllMocks()
getSession.mockResolvedValue({ user: { id: 'user-1' } })
resolveWorkflowIdForUser.mockResolvedValue({
status: 'resolved',
workflowId: 'wf-1',
workflowName: 'Workflow One',
})
+2 -4
View File
@@ -420,10 +420,8 @@ async function resolveBranch(params: {
workflowName,
requestedWorkspaceId
)
if (!resolved) {
return createBadRequestResponse(
'No workflows found. Create a workflow first or provide a valid workflowId.'
)
if (resolved.status !== 'resolved') {
return createBadRequestResponse(resolved.message)
}
const resolvedWorkflowId = resolved.workflowId
+30 -1
View File
@@ -11,6 +11,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, inArray, isNull } from 'drizzle-orm'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
@@ -73,6 +74,23 @@ export interface WorkspaceMdData {
}>
}
function normalizeFolderPathForVfs(folderPath?: string | null): string | null {
if (!folderPath) return null
const segments = folderPath
.split('/')
.map((segment) => normalizeVfsSegment(segment))
.filter(Boolean)
return segments.length > 0 ? segments.join('/') : null
}
function buildWorkflowStatePath(workflowName: string, folderPath?: string | null): string {
const normalizedFolderPath = normalizeFolderPathForVfs(folderPath)
const normalizedWorkflowName = normalizeVfsSegment(workflowName)
return normalizedFolderPath
? `workflows/${normalizedFolderPath}/${normalizedWorkflowName}/state.json`
: `workflows/${normalizedWorkflowName}/state.json`
}
/**
* Pure formatting: build WORKSPACE.md content from pre-fetched data.
* No DB access — callers are responsible for providing the data.
@@ -115,10 +133,20 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string {
if (wf.isDeployed) flags.push('deployed')
if (wf.lastRunAt) flags.push(`last run: ${wf.lastRunAt.toISOString().split('T')[0]}`)
if (flags.length > 0) parts[0] += ` — ${flags.join(', ')}`
if (wf.folderPath) {
parts.push(
`${indent} VFS state path: \`${buildWorkflowStatePath(wf.name, wf.folderPath)}\``
)
}
return parts.join('\n')
}
const lines: string[] = []
if (data.workflows.some((workflow) => workflow.folderPath)) {
lines.push(
'Use the canonical VFS state path shown under nested workflows. Do not infer nested workflow paths from the leaf workflow name alone.'
)
}
for (const wf of rootWorkflows) {
lines.push(formatWf(wf, ''))
}
@@ -379,7 +407,8 @@ export async function generateWorkspaceContext(
const folder = folderById.get(id)
if (!folder) return id
const parentPath = folder.parentId ? resolveFolderPath(folder.parentId) : ''
const path = parentPath ? `${parentPath}/${folder.name}` : folder.name
const normalizedName = normalizeVfsSegment(folder.name)
const path = parentPath ? `${parentPath}/${normalizedName}` : normalizedName
folderPathMap.set(id, path)
return path
}
+3 -1
View File
@@ -422,13 +422,14 @@ export function serializeBlockSchema(block: BlockConfig): string {
/**
* Serialize OAuth credentials for VFS environment/credentials.json.
* Shows which integrations are connected — IDs and scopes, NOT tokens.
* Shows which integrations are connected — IDs, roles, and scopes, NOT tokens.
*/
export function serializeCredentials(
accounts: Array<{
id?: string
providerId: string
displayName?: string | null
role?: string | null
scope: string | null
createdAt: Date
}>
@@ -438,6 +439,7 @@ export function serializeCredentials(
id: a.id || undefined,
provider: a.providerId,
displayName: a.displayName || undefined,
role: a.role || undefined,
scope: a.scope || undefined,
connectedAt: a.createdAt.toISOString(),
})),
@@ -1367,6 +1367,7 @@ export class WorkspaceVFS {
id: c.id,
providerId: c.providerId,
displayName: c.displayName,
role: c.role,
scope: null,
createdAt: c.updatedAt,
})),
+3
View File
@@ -367,6 +367,7 @@ export interface AccessibleOAuthCredential {
id: string
providerId: string
displayName: string
role: 'admin' | 'member'
updatedAt: Date
}
@@ -379,6 +380,7 @@ export async function getAccessibleOAuthCredentials(
id: credential.id,
providerId: credential.providerId,
displayName: credential.displayName,
role: credentialMember.role,
updatedAt: credential.updatedAt,
})
.from(credential)
@@ -403,6 +405,7 @@ export async function getAccessibleOAuthCredentials(
id: row.id,
providerId: row.providerId!,
displayName: row.displayName,
role: row.role,
updatedAt: row.updatedAt,
}))
}
+75 -10
View File
@@ -103,12 +103,32 @@ export async function deduplicateWorkflowName(
return `${name} (${generateId().slice(0, 6)})`
}
export type WorkflowResolutionResult =
| {
status: 'resolved'
workflowId: string
workflowName?: string
}
| {
status: 'not_found'
message: string
}
| {
status: 'ambiguous'
message: string
candidates: Array<{
workflowId: string
workflowName?: string
folderId?: string | null
}>
}
export async function resolveWorkflowIdForUser(
userId: string,
workflowId?: string,
workflowName?: string,
workspaceId?: string
): Promise<{ workflowId: string; workflowName?: string } | null> {
): Promise<WorkflowResolutionResult> {
if (workflowId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
@@ -116,10 +136,13 @@ export async function resolveWorkflowIdForUser(
action: 'read',
})
if (!authorization.allowed) {
return null
return {
status: 'not_found',
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
}
}
const wf = await getWorkflowById(workflowId)
return { workflowId, workflowName: wf?.name || undefined }
return { status: 'resolved', workflowId, workflowName: wf?.name || undefined }
}
const workspaceIds = await db
@@ -132,7 +155,10 @@ export async function resolveWorkflowIdForUser(
? workspaceIdList.filter((candidateWorkspaceId) => candidateWorkspaceId === workspaceId)
: workspaceIdList
if (allowedWorkspaceIds.length === 0) {
return null
return {
status: 'not_found',
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
}
}
const workflows = await db
@@ -144,23 +170,62 @@ export async function resolveWorkflowIdForUser(
.orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt), asc(workflowTable.id))
if (workflows.length === 0) {
return null
return {
status: 'not_found',
message: 'No workflows found. Create a workflow first or provide a valid workflowId.',
}
}
if (workflowName) {
const match = workflows.find(
const matches = workflows.filter(
(w) =>
String(w.name || '')
.trim()
.toLowerCase() === workflowName.toLowerCase()
)
if (match) {
return { workflowId: match.id, workflowName: match.name || undefined }
if (matches.length === 1) {
const [match] = matches
return {
status: 'resolved',
workflowId: match.id,
workflowName: match.name || undefined,
}
}
if (matches.length > 1) {
return {
status: 'ambiguous',
message: `Multiple workflows named "${workflowName}" were found. Provide workflowId to disambiguate.`,
candidates: matches.map((match) => ({
workflowId: match.id,
workflowName: match.name || undefined,
folderId: match.folderId,
})),
}
}
return {
status: 'not_found',
message: `No workflow named "${workflowName}" was found.`,
}
return null
}
return { workflowId: workflows[0].id, workflowName: workflows[0].name || undefined }
if (workflows.length === 1) {
return {
status: 'resolved',
workflowId: workflows[0].id,
workflowName: workflows[0].name || undefined,
}
}
return {
status: 'ambiguous',
message:
'Multiple workflows are available. Provide workflowId or workflowName to disambiguate.',
candidates: workflows.slice(0, 20).map((workflow) => ({
workflowId: workflow.id,
workflowName: workflow.name || undefined,
folderId: workflow.folderId,
})),
}
}
type WorkflowRecord = ReturnType<typeof getWorkflowById> extends Promise<infer R>