Skip to content
Merged
12 changes: 11 additions & 1 deletion apps/docs/content/docs/en/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4615,7 +4615,7 @@ Also spelled `sim workflow`.
Activate Workflow Version (personal API key required)

```bash
sim workflows activate create <workflowId> <version>
sim workflows activate create <workflowId> <version> [options]
```

**Arguments**
Expand All @@ -4629,6 +4629,16 @@ sim workflows activate create <workflowId> <version>

</CommandTable>

**Options**

<CommandTable>

| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this operation. |

</CommandTable>

### sim workflows operations apply

Apply Workflow Operations (personal API key required)
Expand Down
12 changes: 11 additions & 1 deletion apps/docs/content/docs/en/cli/workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio
## Activate workflow version

```bash
sim workflows activate create <workflowId> <version>
sim workflows activate create <workflowId> <version> [options]
```

Activate Workflow Version (personal API key required)
Expand All @@ -28,6 +28,16 @@ Activate Workflow Version (personal API key required)

</CommandTable>

**Options**

<CommandTable>

| Option | Required | Description |
| --- | --- | --- |
| `-y, --yes` | Yes | Confirm this operation. |

</CommandTable>

## Apply workflow operations

```bash
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -2098,9 +2098,9 @@
"name": "parentPath",
"in": "query",
"required": false,
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"schema": {
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"$ref": "#/components/schemas/FolderPathInput"
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-knowledge.json
Original file line number Diff line number Diff line change
Expand Up @@ -2718,9 +2718,9 @@
"name": "parentPath",
"in": "query",
"required": false,
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"schema": {
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"$ref": "#/components/schemas/FolderPathInput"
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -3726,9 +3726,9 @@
"name": "parentPath",
"in": "query",
"required": false,
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"schema": {
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"$ref": "#/components/schemas/FolderPathInput"
}
},
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -3206,9 +3206,9 @@
"name": "parentPath",
"in": "query",
"required": false,
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"schema": {
"description": "Restrict results to direct children of this parent path.",
"description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.",
"$ref": "#/components/schemas/FolderPathInput"
}
},
Expand Down
12 changes: 7 additions & 5 deletions apps/sim/lib/api/contracts/v2/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,10 @@ export const v2SearchSchema = z
* to nothing, exactly as `workflowIds` naming no workflow does. These lists used
* to answer `404 Folder not found` instead, which reported a missing collection
* for a collection that exists, broke a pagination walk when a folder was
* deleted mid-walk, and made a list a folder-existence oracle. The sibling
* folder lists already answered a non-matching `parentPath` with an empty page.
* Mutations keep their 404 — creating into or moving to a folder that does not
* exist has no empty-set reading.
* deleted mid-walk, and made a list a folder-existence oracle. The folder lists
* answer a non-matching `parentPath` the same way, so one rule covers every
* folder filter in the family. Mutations keep their 404 — creating into or
* moving to a folder that does not exist has no empty-set reading.
*/
export const V2_FOLDER_FILTER_MISS =
'A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.'
Expand Down Expand Up @@ -593,7 +593,9 @@ export const v2ListFoldersQuerySchema = z
workspaceId: workspaceIdSchema.describe('Workspace whose folders should be listed.'),
parentPath: v2FolderPathInputSchema
.optional()
.describe('Restrict results to direct children of this parent path.'),
.describe(
`Restrict results to direct children of this parent path. ${V2_FOLDER_FILTER_MISS}`
),
search: v2SearchSchema.describe('Case-insensitive substring match against the folder name.'),
...v2SortFields(v2FolderSortFields, { sortBy: 'name', sortOrder: 'asc' }),
})
Expand Down
45 changes: 45 additions & 0 deletions apps/sim/lib/catalog/application/catalog-reads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ const TOOL_METADATA: Record<string, Record<string, unknown>> = {
params: {},
hostedApiKey: 'always',
},
confluence_read: {
id: 'confluence_read',
name: 'Confluence Read',
description: 'Read a Confluence page.',
version: '1.0.0',
params: {},
hostedApiKey: 'none',
},
confluence_read_v2: {
id: 'confluence_read_v2',
name: 'Confluence Read',
description: 'Read a Confluence page.',
version: '2.0.0',
params: {},
hostedApiKey: 'none',
},
}

const WORKSPACE_ID = 'workspace-1'
Expand Down Expand Up @@ -186,6 +202,7 @@ const confluenceV2 = block({
type: 'confluence_v2',
name: 'Confluence',
description: 'Read Confluence pages.',
tools: { access: ['confluence_read_v2'] },
})

interface Visibility {
Expand Down Expand Up @@ -566,6 +583,34 @@ describe('catalog block and tool reads', () => {
expect(listed.entries.map((entry) => entry.hostedApiKey)).toEqual(['none', 'none'])
})

/**
* A superseded v1 tool stays registered so execution of a stored id keeps
* working, so `resolveToolId('confluence_read')` answers with the v1 id no
* visible block exposes — and `GET /v2/tools/confluence_read` 404'd while the
* list published `confluence_read_v2`.
*/
it('resolves an unversioned tool name to its newest visible version and echoes the resolved id', async () => {
mocks.getAllBlocks.mockReturnValue([confluenceV2])

const { tool } = await getCatalogTool.execute({
principal: session,
input: { workspaceId: WORKSPACE_ID, toolId: 'confluence_read' },
})

expect(tool.id).toBe('confluence_read_v2')
})

it('echoes an exact versioned tool id unchanged', async () => {
mocks.getAllBlocks.mockReturnValue([confluenceV2])

const { tool } = await getCatalogTool.execute({
principal: session,
input: { workspaceId: WORKSPACE_ID, toolId: 'confluence_read_v2' },
})

expect(tool.id).toBe('confluence_read_v2')
})

it('reads one tool with its params and outputs', async () => {
const { tool } = await getCatalogTool.execute({
principal: session,
Expand Down
23 changes: 13 additions & 10 deletions apps/sim/lib/catalog/application/get-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import {
resolveCatalogGate,
} from '@/lib/catalog/application/catalog-context'
import { catalogOperations } from '@/lib/catalog/application/operations'
import { resolveVisibleToolIds } from '@/lib/catalog/application/tool-scope'
import { resolveVisibleToolId, resolveVisibleToolIds } from '@/lib/catalog/application/tool-scope'
import { type CatalogToolDetail, projectToolDetail } from '@/lib/catalog/projection/tool'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { isHosted } from '@/lib/core/config/env-flags'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { resolveToolId } from '@/tools/tool-ids'

export interface GetCatalogToolInput {
workspaceId: string
Expand All @@ -22,27 +21,31 @@ export interface GetCatalogToolResult {
/**
* One built-in tool's parameters and outputs.
*
* An unversioned name resolves to the newest version exactly as execution does,
* and the returned `id` is the resolved one so a caller can see which version
* answered. A tool the workspace's blocks do not expose answers 404 rather than
* 403, for the same enumeration reason as the block detail read.
* An unversioned name resolves to the newest version this caller can see, the
* way the block detail read does, and the returned `id` is the resolved one so
* a caller can see which version answered. Resolving through
* `@/tools/tool-ids` instead would answer with the superseded v1 that stays
* registered for execution's sake, which no visible block exposes — so every
* versioned family 404'd on the base name. A tool the workspace's blocks do not
* expose answers 404 rather than 403, for the same enumeration reason as the
* block detail read.
*/
export const getCatalogTool = defineAuthorizedWorkspaceUseCase({
operation: catalogOperations.readTool,
resolveContext: ({ input }: { input: GetCatalogToolInput }) =>
loadCatalogWorkspaceContext(input.workspaceId),
authorizationOptions: {},
execute: async ({ principal, input, context }): Promise<GetCatalogToolResult> => {
const resolvedToolId = resolveToolId(input.toolId)
const tool = projectToolDetail(resolvedToolId, { hostedKeys: isHosted })
if (!tool) throw new OrchestrationError('not_found', 'Tool not found')

const gate = await resolveCatalogGate(principal, context)
const visibleToolIds = await resolveVisibleToolIds(gate)
const resolvedToolId = resolveVisibleToolId(input.toolId, visibleToolIds)
if (!visibleToolIds.has(resolvedToolId)) {
throw new OrchestrationError('not_found', 'Tool not found')
}

const tool = projectToolDetail(resolvedToolId, { hostedKeys: isHosted })
if (!tool) throw new OrchestrationError('not_found', 'Tool not found')

return { tool }
},
})
35 changes: 35 additions & 0 deletions apps/sim/lib/catalog/application/tool-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ import {
import { getAllBlocks } from '@/blocks/registry'
import { resolveToolId } from '@/tools/tool-ids'

const VERSION_SUFFIX = /^\d+$/

/**
* The newest visible version of a tool name, or the name unchanged when none is.
*
* The detail-read counterpart of {@link resolveVisibleToolIds}, and the tool
* analogue of `getLatestBlockForViewer`: "newest registered" and "newest visible
* to this caller" are different questions. `@/tools/tool-ids` answers the first
* — and superseded v1 tools stay registered so execution of a stored id keeps
* working, so `resolveToolId('github_comment')` returns `github_comment`, which
* no visible block exposes. Resolving against the visible set instead walks down
* to `github_comment_v2`, the id the tool list publishes.
*
* An id that is itself visible is returned untouched, so an exact versioned
* request never silently answers with a different version.
*/
export function resolveVisibleToolId(toolId: string, visibleToolIds: ReadonlySet<string>): string {
if (visibleToolIds.has(toolId)) return toolId

const prefix = `${toolId}_v`
let bestId: string | undefined
let bestVersion = 0
for (const candidate of visibleToolIds) {
if (!candidate.startsWith(prefix)) continue
const suffix = candidate.slice(prefix.length)
if (!VERSION_SUFFIX.test(suffix)) continue
const version = Number.parseInt(suffix, 10)
if (version > bestVersion) {
bestVersion = version
bestId = candidate
}
}
return bestId ?? toolId
}

/**
* The built-in tools this caller may run in this workspace.
*
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/folders/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ export type FolderPathFilter =
* event from all the others, told a caller its *collection* was missing when it
* was not, turned a folder deleted mid-walk into a failed pagination loop, and
* answered whether a path exists on an endpoint that was not asked. The sibling
* folder lists already answer a non-matching `parentPath` with an empty page, so
* this is the family's existing behavior applied to the resource lists too.
* folder lists answer a non-matching `parentPath` the same way, so one rule
* covers every folder filter the family accepts.
*
* A path that could not name a folder at all is still rejected by the contract,
* as a 400, before any of this runs. Mutations keep their 404: creating into or
Expand Down
25 changes: 18 additions & 7 deletions apps/sim/lib/knowledge/application/folders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ vi.mock('@/lib/knowledge/application/contexts', () => ({
vi.mock('@/lib/folders/queries', () => ({
loadActiveFolderPathIndex: mocks.loadIndex,
listActiveFolderRows: mocks.listRows,
resolveFolderPathFilter: (index: { idByPath: Map<string, string> }, path: string | undefined) => {
if (path === undefined) return { kind: 'unfiltered' }
if (path === '/') return { kind: 'folder', folderId: null }
const folderId = index.idByPath.get(path)
return folderId === undefined ? { kind: 'noMatch' } : { kind: 'folder', folderId }
},
resolveFolderPathFromIndex: (index: { idByPath: Map<string, string> }, path: string) =>
path === '/' ? null : index.idByPath.get(path),
}))
Expand Down Expand Up @@ -112,14 +118,19 @@ describe('knowledge folder application use cases', () => {
expect(result.folders[0]).toMatchObject({ id: 'folder-1', path: '/Docs' })
})

it('rejects a missing parent without querying folder rows', async () => {
await expect(
listKnowledgeFolders.execute({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { workspaceId: 'workspace-1', parentPath: '/Missing' },
})
).rejects.toMatchObject({ code: 'not_found' })
/**
* `parentPath` is a filter, so a path naming no active folder narrows the
* result to nothing rather than reporting the collection missing. Falling
* through to `listActiveFolderRows` with an undefined `parentId` would list
* every folder in the workspace, so the miss has to short-circuit.
*/
it('answers a parent path naming no folder with an empty page', async () => {
const result = await listKnowledgeFolders.execute({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { workspaceId: 'workspace-1', parentPath: '/Missing' },
})

expect(result.folders).toEqual([])
expect(mocks.listRows).not.toHaveBeenCalled()
})

Expand Down
12 changes: 4 additions & 8 deletions apps/sim/lib/knowledge/application/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ROOT_FOLDER_PATH } from '@/lib/folders/paths'
import {
listActiveFolderRows,
loadActiveFolderPathIndex,
resolveFolderPathFilter,
resolveFolderPathFromIndex,
} from '@/lib/folders/queries'
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
Expand Down Expand Up @@ -67,15 +68,10 @@ export const listKnowledgeFolders = defineAuthorizedKnowledgeUseCase({
undefined,
{ maxRows: MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE }
)
const parentId =
input.parentPath === undefined
? undefined
: resolveFolderPathFromIndex(index, input.parentPath)
if (input.parentPath !== undefined && parentId === undefined) {
throw new OrchestrationError('not_found', 'Folder not found')
}
const parentFilter = resolveFolderPathFilter(index, input.parentPath)
if (parentFilter.kind === 'noMatch') return { folders: [] }
const folders = await listActiveFolderRows(context.workspaceId, 'knowledge_base', {
parentId,
parentId: parentFilter.kind === 'folder' ? parentFilter.folderId : undefined,
search: input.search,
sortBy: input.sortBy,
sortOrder: input.sortOrder,
Expand Down
Loading
Loading