v0.6.42: mothership nested file reads, search modal improvements

This commit is contained in:
Waleed
2026-04-14 13:07:50 -07:00
committed by GitHub
22 changed files with 361 additions and 78 deletions

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

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 }
)

View File

@@ -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({

View File

@@ -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
}

View File

@@ -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)]'>

View File

@@ -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)
@@ -650,7 +673,10 @@ function extractResourceFromReadResult(
): MothershipResource | null {
if (!path) return null
const segments = path.split('/')
const segments = path
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
const resourceType = VFS_DIR_TO_RESOURCE[segments[0]]
if (!resourceType || !segments[1]) return null
@@ -670,8 +696,22 @@ function extractResourceFromReadResult(
}
}
const fallbackTitle =
resourceType === 'workflow'
? resolveLeafWorkflowPathSegment(segments)
: segments[1] || segments[segments.length - 1]
if (!id) return null
return { type: resourceType, id, title: name || segments[1] }
return { type: resourceType, id, title: name || fallbackTitle || id }
}
function resolveLeafWorkflowPathSegment(segments: string[]): string | undefined {
const lastSegment = segments[segments.length - 1]
if (!lastSegment) return undefined
if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) {
return segments[segments.length - 2]
}
return lastSegment
}
export interface UseChatOptions {
@@ -1396,6 +1436,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 +1459,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 +1598,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 +1660,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 +1859,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 +1963,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 +2009,7 @@ export function useChat(
status: 'executing',
displayTitle,
params: args,
calledBy: activeSubagent,
calledBy: scopedSubagent,
},
})
if (name === ReadTool.id || isResourceToolName(name)) {
@@ -2064,23 +2125,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 +2160,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 +2172,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 +2188,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: {

View File

@@ -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]

View File

@@ -58,7 +58,7 @@ export const MemoizedWorkflowItem = memo(
onSelect: () => void
color: string
name: string
folderPath?: string
folderPath?: string[]
isCurrent?: boolean
}) {
return (
@@ -71,13 +71,21 @@ export const MemoizedWorkflowItem = memo(
backgroundClip: 'padding-box',
}}
/>
<span className='truncate font-base text-[var(--text-body)]'>
{name}
{isCurrent && ' (current)'}
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{folderPath && (
<span className='ml-auto min-w-0 truncate pl-2 font-base text-[var(--text-subtle)] text-small'>
{folderPath}
{folderPath && folderPath.length > 0 && (
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)}
</Command.Item>
@@ -87,8 +95,10 @@ export const MemoizedWorkflowItem = memo(
prev.value === next.value &&
prev.color === next.color &&
prev.name === next.name &&
prev.folderPath === next.folderPath &&
prev.isCurrent === next.isCurrent
prev.isCurrent === next.isCurrent &&
(prev.folderPath === next.folderPath ||
(prev.folderPath?.length === next.folderPath?.length &&
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
)
export const MemoizedTaskItem = memo(
@@ -127,9 +137,9 @@ export const MemoizedWorkspaceItem = memo(
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='truncate font-base text-[var(--text-body)]'>
{name}
{isCurrent && ' (current)'}
<span className='flex min-w-0 font-base text-[var(--text-body)]'>
<span className='truncate'>{name}</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
</Command.Item>
)

View File

@@ -163,7 +163,7 @@ export const WorkflowsGroup = memo(function WorkflowsGroup({
{items.map((workflow) => (
<MemoizedWorkflowItem
key={workflow.id}
value={`${workflow.name} ${workflow.folderPath ?? ''} workflow-${workflow.id}`}
value={`${workflow.name} ${workflow.folderPath?.join(' / ') ?? ''} workflow-${workflow.id}`}
onSelect={() => onSelect(workflow)}
color={workflow.color}
name={workflow.name}

View File

@@ -11,7 +11,7 @@ export interface WorkflowItem {
name: string
href: string
color: string
folderPath?: string
folderPath?: string[]
isCurrent?: boolean
}

View File

@@ -637,16 +637,14 @@ export const Sidebar = memo(function Sidebar() {
() =>
regularWorkflows.map((workflow) => {
const folderPath = workflow.folderId
? getFolderPath(folderMap, workflow.folderId)
.map((folder) => folder.name)
.join(' / ')
: ''
? getFolderPath(folderMap, workflow.folderId).map((folder) => folder.name)
: []
return {
id: workflow.id,
name: workflow.name,
href: `/workspace/${workspaceId}/w/${workflow.id}`,
color: workflow.color,
folderPath: folderPath || undefined,
folderPath: folderPath.length > 0 ? folderPath : undefined,
isCurrent: workflow.id === workflowId,
}
}),

View File

@@ -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,
},
},
])
})
})

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,
}

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',
})

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

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
}

View File

@@ -29,6 +29,12 @@ describe('resolveToolDisplay', () => {
path: 'workflows/My Workflow/meta.json',
})?.text
).toBe('Read My Workflow')
expect(
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
path: 'workflows/Folder 1/RET XYZ/state.json',
})?.text
).toBe('Read RET XYZ')
})
it('falls back to a humanized tool label for generic tools', () => {

View File

@@ -98,10 +98,26 @@ function describeReadTarget(path: string | undefined): string | undefined {
return segments.slice(1).join('/') || segments[segments.length - 1]
}
if (resourceType === 'workflow') {
return stripExtension(getLeafResourceSegment(segments))
}
const resourceName = segments[1] || segments[segments.length - 1]
return stripExtension(resourceName)
}
function getLeafResourceSegment(segments: string[]): string {
const lastSegment = segments[segments.length - 1] || ''
if (hasFileExtension(lastSegment) && segments.length > 1) {
return segments[segments.length - 2] || lastSegment
}
return lastSegment
}
function hasFileExtension(value: string): boolean {
return /\.[^/.]+$/.test(value)
}
function stripExtension(value: string): string {
return value.replace(/\.[^/.]+$/, '')
}

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(),
})),

View File

@@ -1367,6 +1367,7 @@ export class WorkspaceVFS {
id: c.id,
providerId: c.providerId,
displayName: c.displayName,
role: c.role,
scope: null,
createdAt: c.updatedAt,
})),

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,
}))
}

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>