diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53553f9c614b..f0122e35f20f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -474,6 +474,9 @@ importers: '@radix-ui/react-form': specifier: ^0.1.0 version: 0.1.0(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-label': + specifier: ^2.1.0 + version: 2.1.0(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0) @@ -528,6 +531,9 @@ importers: '@vscode/webview-ui-toolkit': specifier: ^1.2.2 version: 1.2.2(react@18.2.0) + '@xyflow/react': + specifier: ^12.3.4 + version: 12.3.4(@types/react@18.2.79)(immer@10.1.1)(react-dom@18.2.0)(react@18.2.0) agent-base: specifier: ^7.1.1 version: 7.1.1 @@ -878,6 +884,12 @@ importers: specifier: ^3.23.8 version: 3.23.8 + vscode/vscode: + dependencies: + '@radix-ui/react-label': + specifier: ^2.1.0 + version: 2.1.0(react-dom@18.2.0)(react@18.3.1) + web: devDependencies: '@openctx/vscode-lib': @@ -3990,6 +4002,18 @@ packages: react: 18.3.1 dev: true + /@radix-ui/react-compose-refs@1.1.0(react@18.3.1): + resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + react: 18.3.1 + dev: false + /@radix-ui/react-context@1.0.1(@types/react@18.2.37)(react@18.2.0): resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} peerDependencies: @@ -4338,6 +4362,24 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-label@2.1.0(react-dom@18.2.0)(react@18.3.1): + resolution: {integrity: sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.0.0(react-dom@18.2.0)(react@18.3.1) + react: 18.3.1 + react-dom: 18.2.0(react@18.3.1) + dev: false + /@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==} peerDependencies: @@ -4572,6 +4614,24 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-primitive@2.0.0(react-dom@18.2.0)(react@18.3.1): + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.1.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.2.0(react@18.3.1) + dev: false + /@radix-ui/react-progress@1.1.0(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==} peerDependencies: @@ -4704,6 +4764,19 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-slot@1.1.0(react@18.3.1): + resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(react@18.3.1) + react: 18.3.1 + dev: false + /@radix-ui/react-tabs@1.1.0(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==} peerDependencies: @@ -6461,6 +6534,39 @@ packages: resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} dev: true + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-drag@3.0.7: + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + dependencies: + '@types/d3-selection': 3.0.11 + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-selection@3.0.11: + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + dev: false + + /@types/d3-transition@3.0.9: + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + dependencies: + '@types/d3-selection': 3.0.11 + dev: false + + /@types/d3-zoom@3.0.8: + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + dev: false + /@types/debug@4.1.12: resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} dependencies: @@ -7081,6 +7187,34 @@ packages: engines: {node: '>=10.0.0'} dev: false + /@xyflow/react@12.3.4(@types/react@18.2.79)(immer@10.1.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-KjFkj84S+wK8aJF/PORxSkOAeotTTPz++hus+Y95NFMIJGVyl8jjVaaz5B1zyV0prk6ZkbMp6q0vqMjJdZT25Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@xyflow/system': 0.0.45 + classcat: 5.0.5 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + zustand: 4.5.5(@types/react@18.2.79)(immer@10.1.1)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@xyflow/system@0.0.45: + resolution: {integrity: sha512-szP1LjDD4jlRYYhxvgZqOCTMToUVNqjQkrlhb0fhv1sXomU1+yMDdhpQT+FjE4d+rKx08fS10sOuZUl2ycXaDw==} + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + dev: false + /@yarnpkg/esbuild-plugin-pnp@3.0.0-rc.15(esbuild@0.18.20): resolution: {integrity: sha512-kYzDJO5CA9sy+on/s2aIW0411AklfCi8Ck/4QDivOqsMKpStZA2SsR+X27VTggGwpStWaLrjJcDcdDMowtG8MA==} engines: {node: '>=14.15.0'} @@ -8116,6 +8250,10 @@ packages: clsx: 2.0.0 dev: false + /classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + dev: false + /classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} dev: true @@ -8553,6 +8691,71 @@ packages: resolution: {integrity: sha512-wT5Y7mO8abrV16gnssKdmIhIbA9wSkeMzhh27jAguKrV82i24wER0vL5TGhUJ9dbJNDcigoRZ0IAHFEEEI4THQ==} dev: false + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + dev: false + + /d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + + /d3-transition@3.0.1(d3-selection@3.0.0): + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + dev: false + + /d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + dev: false + /data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -13836,7 +14039,6 @@ packages: loose-envify: 1.4.0 react: 18.3.1 scheduler: 0.23.2 - dev: true /react-element-to-jsx-string@15.0.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==} @@ -14039,7 +14241,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - dev: true /read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -16160,6 +16361,14 @@ packages: tslib: 2.7.0 dev: false + /use-sync-external-store@1.2.2(react@18.2.0): + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /use@3.1.1: resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} engines: {node: '>=0.10.0'} @@ -16902,6 +17111,27 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: true + /zustand@4.5.5(@types/react@18.2.79)(immer@10.1.1)(react@18.2.0): + resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.79 + immer: 10.1.1 + react: 18.2.0 + use-sync-external-store: 1.2.2(react@18.2.0) + dev: false + /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false diff --git a/vscode/package.json b/vscode/package.json index c90f4781bf5b..ee04a6fcb7c6 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -595,6 +595,11 @@ "command": "cody.command.insertCodeToNewFile", "title": "Save Code to New File...", "enablement": "cody.activated" + }, + { + "command": "cody.openWorkflowEditor", + "title": "Open Workflow Editor", + "category": "Cody" } ], "keybindings": [ @@ -1386,6 +1391,7 @@ "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-form": "^0.1.0", + "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-slot": "^1.0.2", @@ -1404,6 +1410,7 @@ "@types/react-dom": "18.2.25", "@vscode/codicons": "^0.0.35", "@vscode/webview-ui-toolkit": "^1.2.2", + "@xyflow/react": "^12.3.4", "agent-base": "^7.1.1", "async-mutex": "^0.4.0", "axios": "^1.3.6", diff --git a/vscode/src/main.ts b/vscode/src/main.ts index b9f5635e826d..4ab3a1faa69f 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -109,6 +109,7 @@ import { openCodyIssueReporter } from './services/utils/issue-reporter' import { SupercompletionProvider } from './supercompletions/supercompletion-provider' import { parseAllVisibleDocuments, updateParseTreeOnEdit } from './tree-sitter/parse-tree-cache' import { version } from './version' +import { registerWorkflowCommands } from './workflow/workflow' /** * Start the extension, watching all relevant configuration and secrets for changes. @@ -288,6 +289,7 @@ const register = async ( registerOtherCommands(disposables) if (clientCapabilities().isVSCode) { registerVSCodeOnlyFeatures(chatClient, disposables) + registerWorkflowCommands(context, chatClient) } if (isExtensionModeDevOrTest) { await registerTestCommands(context, disposables) diff --git a/vscode/src/workflow/__tests__/workflow.test.ts b/vscode/src/workflow/__tests__/workflow.test.ts new file mode 100644 index 000000000000..92f908311227 --- /dev/null +++ b/vscode/src/workflow/__tests__/workflow.test.ts @@ -0,0 +1,67 @@ +import { v4 as uuidv4 } from 'uuid' +import { expect, test } from 'vitest' +import type { NodeType, WorkflowNode } from '../../../webviews/workflow/components/nodes/Nodes' +import { topologicalSort } from '../workflow-executor' + +test('workflow executes correctly with UUID node IDs', () => { + const id1 = uuidv4() + const id2 = uuidv4() + + const nodes: WorkflowNode[] = [ + { + id: id1, + type: 'cli' as NodeType, + data: { label: 'CLI Node', command: 'echo "hello"' }, + position: { x: 0, y: 0 }, + }, + { + id: id2, + type: 'preview' as NodeType, + data: { label: 'Preview Node' }, + position: { x: 0, y: 0 }, + }, + ] + const edges = [{ id: uuidv4(), source: id1, target: id2 }] + + const sortedNodes = topologicalSort(nodes, edges) + expect(sortedNodes[0].id).toBe(id1) + expect(sortedNodes[1].id).toBe(id2) +}) + +test('topology sort maintains order with UUID nodes', () => { + const id1 = uuidv4() + const id2 = uuidv4() + const id3 = uuidv4() + + const nodes: WorkflowNode[] = [ + { + id: id1, + type: 'cli' as NodeType, + data: { label: 'First CLI' }, + position: { x: 0, y: 0 }, + }, + { + id: id2, + type: 'llm' as NodeType, + data: { label: 'LLM Node' }, + position: { x: 0, y: 0 }, + }, + { + id: id3, + type: 'preview' as NodeType, + data: { label: 'Preview' }, + position: { x: 0, y: 0 }, + }, + ] + + const edges = [ + { id: uuidv4(), source: id1, target: id2 }, + { id: uuidv4(), source: id2, target: id3 }, + ] + + const sortedNodes = topologicalSort(nodes, edges) + expect(sortedNodes).toHaveLength(3) + expect(sortedNodes[0].id).toBe(id1) + expect(sortedNodes[1].id).toBe(id2) + expect(sortedNodes[2].id).toBe(id3) +}) diff --git a/vscode/src/workflow/workflow-executor.ts b/vscode/src/workflow/workflow-executor.ts new file mode 100644 index 000000000000..d0c314393f62 --- /dev/null +++ b/vscode/src/workflow/workflow-executor.ts @@ -0,0 +1,378 @@ +import { exec } from 'node:child_process' +import * as os from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import * as vscode from 'vscode' + +import { type ChatClient, type Message, PromptString } from '@sourcegraph/cody-shared' +import type { Edge } from '../../webviews/workflow/components/CustomOrderedEdge' +import type { WorkflowNode } from '../../webviews/workflow/components/nodes/Nodes' +import type { WorkflowFromExtension } from '../../webviews/workflow/services/WorkflowProtocol' + +interface ExecutionContext { + nodeOutputs: Map +} + +const execAsync = promisify(exec) + +/** + * Performs a topological sort on the given workflow nodes and edges, returning the sorted nodes. + * + * @param nodes - The workflow nodes to sort. + * @param edges - The edges between the workflow nodes. + * @returns The sorted workflow nodes. + */ +export function topologicalSort(nodes: WorkflowNode[], edges: Edge[]): WorkflowNode[] { + const graph = new Map() + const inDegree = new Map() + + // Initialize + for (const node of nodes) { + graph.set(node.id, []) + inDegree.set(node.id, 0) + } + + // Build graph + for (const edge of edges) { + graph.get(edge.source)?.push(edge.target) + inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1) + } + + // Find nodes with no dependencies but sort them based on their edge connections + const sourceNodes = nodes.filter(node => inDegree.get(node.id) === 0) + + // Sort source nodes based on edge order + const sortedSourceNodes = sourceNodes.sort((a, b) => { + const aEdgeIndex = edges.findIndex(edge => edge.source === a.id) + const bEdgeIndex = edges.findIndex(edge => edge.source === b.id) + return aEdgeIndex - bEdgeIndex + }) + + const queue = sortedSourceNodes.map(node => node.id) + const result: string[] = [] + + while (queue.length > 0) { + const nodeId = queue.shift()! + result.push(nodeId) + + const neighbors = graph.get(nodeId) + if (neighbors) { + for (const neighbor of neighbors) { + inDegree.set(neighbor, (inDegree.get(neighbor) || 0) - 1) + if (inDegree.get(neighbor) === 0) { + queue.push(neighbor) + } + } + } + } + + return result.map(id => nodes.find(node => node.id === id)!).filter(Boolean) +} + +/** + * Executes a CLI node in a workflow, running the specified shell command and returning its output. + * + * @param node - The workflow node to execute. + * @returns The output of the shell command. + * @throws {Error} If the shell is not available, the workspace is not trusted, or the command fails to execute. + */ +async function executeCLINode(node: WorkflowNode): Promise { + // Check if shell is available and workspace is trusted + if (!vscode.env.shell || !vscode.workspace.isTrusted) { + throw new Error('Shell command is not supported in your current workspace.') + } + + // Get workspace directory + const homeDir = os.homedir() || process.env.HOME || process.env.USERPROFILE || '' + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri?.path + + // Filter and sanitize command + const filteredCommand = node.data.command?.replaceAll(/(\s~\/)/g, ` ${homeDir}${path.sep}`) || '' + + // Check for disallowed commands (you'll need to define commandsNotAllowed array) + if (commandsNotAllowed.some(cmd => filteredCommand.startsWith(cmd))) { + void vscode.window.showErrorMessage('Cody cannot execute this command') + throw new Error('Cody cannot execute this command') + } + + try { + const { stdout, stderr } = await execAsync(filteredCommand, { cwd }) + + if (stderr) { + throw new Error(stderr) + } + return stdout.replace(/\n$/, '') + } catch (error) { + throw new Error( + `Failed to execute command: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +/** + * Executes Cody AI node in a workflow, using the provided chat client to generate a response based on the specified prompt. + * + * @param node - The workflow node to execute. + * @param chatClient - The chat client to use for generating the LLM response. + * @returns The generated response from the LLM. + * @throws {Error} If no prompt is specified for the LLM node, or if there is an error executing the LLM node. + */ +async function executeLLMNode(node: WorkflowNode, chatClient: ChatClient): Promise { + if (!node.data.prompt) { + throw new Error(`No prompt specified for LLM node ${node.id} with ${node.data.label}`) + } + + try { + // Convert to messages format expected by chat client + const messages: Message[] = [ + { + speaker: 'human', + text: PromptString.unsafe_fromUserQuery(node.data.prompt), + }, + ] + + // Using the chat client directly as seen in chat.ts + const response = await new Promise((resolve, reject) => { + let fullResponse = '' + + // Stream the response and accumulate it + chatClient + .chat(messages, { + stream: false, + maxTokensToSample: 1000, // Adjust as needed + model: 'anthropic::2024-10-22::claude-3-5-sonnet-latest', + }) + .then(async stream => { + try { + for await (const message of stream) { + switch (message.type) { + case 'change': + fullResponse += message.text + break + case 'complete': + resolve(fullResponse) + break + case 'error': + reject(message.error) + break + } + } + } catch (error) { + reject(error) + } + }) + .catch(reject) + }) + + return response + } catch (error) { + throw new Error( + `Failed to execute LLM node: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +async function executePreviewNode(input: string): Promise { + return input +} + +async function executeInputNode(input: string): Promise { + return input +} + +/** + * Replaces indexed placeholders in a template string with the corresponding values from the parentOutputs array. + * + * @param template - The template string containing indexed placeholders. + * @param parentOutputs - The array of parent output values to substitute into the template. + * @returns The template string with the indexed placeholders replaced. + */ +function replaceIndexedInputs(template: string, parentOutputs: string[]): string { + return template.replace(/\${(\d+)}/g, (_match, index) => { + const adjustedIndex = Number.parseInt(index, 10) - 1 + return adjustedIndex >= 0 && adjustedIndex < parentOutputs.length + ? parentOutputs[adjustedIndex] + : '' + }) +} + +/** + * Combines the outputs from parent nodes in a workflow, with optional sanitization for different node types. + * + * @param nodeId - The ID of the current node. + * @param edges - The edges (connections) in the workflow. + * @param context - The execution context, including the stored node outputs. + * @param nodeType - The type of the current node (e.g. 'cli' or 'llm'). + * @returns An array of the combined parent outputs, with optional sanitization. + */ +function combineParentOutputsByConnectionOrder( + nodeId: string, + edges: Edge[], + context: ExecutionContext, + nodeType: string +): string[] { + const parentEdges = edges.filter(edge => edge.target === nodeId) + return parentEdges + .map(edge => { + const output = context.nodeOutputs.get(edge.source) + if (output === undefined) { + return '' + } + if (nodeType === 'cli') { + return sanitizeForShell(output) + } + if (nodeType === 'llm') { + return sanitizeForPrompt(output) + } + return output + }) + .filter(output => output !== undefined) +} + +/** + * Executes a workflow by running each node in the workflow and combining the outputs from parent nodes. + * + * @param nodes - The workflow nodes to execute. + * @param edges - The connections between the workflow nodes. + * @param webview - The VSCode webview instance to send status updates to. + * @param chatClient - The chat client to use for executing LLM nodes. + * @returns A Promise that resolves when the workflow execution is complete. + */ +export async function executeWorkflow( + nodes: WorkflowNode[], + edges: Edge[], + webview: vscode.Webview, + chatClient: ChatClient +): Promise { + const context: ExecutionContext = { + nodeOutputs: new Map(), + } + + const sortedNodes = topologicalSort(nodes, edges) + + webview.postMessage({ + type: 'execution_started', + } as WorkflowFromExtension) + + for (const node of sortedNodes) { + try { + webview.postMessage({ + type: 'node_execution_status', + data: { nodeId: node.id, status: 'running' }, + } as WorkflowFromExtension) + + let result: string + switch (node.type) { + case 'cli': { + const inputs = combineParentOutputsByConnectionOrder( + node.id, + edges, + context, + node.type + ) + const command = node.data.command + ? replaceIndexedInputs(node.data.command, inputs) + : '' + result = await executeCLINode({ ...node, data: { ...node.data, command } }) + break + } + case 'llm': { + const inputs = combineParentOutputsByConnectionOrder( + node.id, + edges, + context, + node.type + ) + const prompt = node.data.prompt ? replaceIndexedInputs(node.data.prompt, inputs) : '' + result = await executeLLMNode( + { ...node, data: { ...node.data, prompt } }, + chatClient + ) + break + } + case 'preview': { + const inputs = combineParentOutputsByConnectionOrder( + node.id, + edges, + context, + node.type + ) + result = await executePreviewNode(inputs.join('\n')) + break + } + + case 'text-format': { + const inputs = combineParentOutputsByConnectionOrder( + node.id, + edges, + context, + node.type + ) + const text = node.data.content ? replaceIndexedInputs(node.data.content, inputs) : '' + result = await executeInputNode(text) + break + } + default: + throw new Error(`Unknown node type: ${node.type}`) + } + + context.nodeOutputs.set(node.id, result) + webview.postMessage({ + type: 'node_execution_status', + data: { nodeId: node.id, status: 'completed', result }, + } as WorkflowFromExtension) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error) + webview.postMessage({ + type: 'node_execution_status', + data: { nodeId: node.id, status: 'error', result: errorMessage }, + } as WorkflowFromExtension) + // Send execution completed message to indicate workflow has stopped + webview.postMessage({ + type: 'execution_completed', + } as WorkflowFromExtension) + void vscode.window.showErrorMessage(errorMessage) + // Exit the function to stop execution + return + } + } + + webview.postMessage({ + type: 'execution_completed', + } as WorkflowFromExtension) +} + +function sanitizeForShell(input: string): string { + return input.replace(/(["\\'$`])/g, '\\$1').replace(/\n/g, ' ') +} + +function sanitizeForPrompt(input: string): string { + return input.replace(/\${/g, '\\${') +} + +const commandsNotAllowed = [ + 'rm', + 'chmod', + 'shutdown', + 'history', + 'user', + 'sudo', + 'su', + 'passwd', + 'chown', + 'chgrp', + 'kill', + 'reboot', + 'poweroff', + 'init', + 'systemctl', + 'journalctl', + 'dmesg', + 'lsblk', + 'lsmod', + 'modprobe', + 'insmod', + 'rmmod', + 'lsusb', + 'lspci', +] diff --git a/vscode/src/workflow/workflow-io.ts b/vscode/src/workflow/workflow-io.ts new file mode 100644 index 000000000000..51e35ca8d4c2 --- /dev/null +++ b/vscode/src/workflow/workflow-io.ts @@ -0,0 +1,74 @@ +import * as vscode from 'vscode' +import { writeToCodyJSON } from '../commands/utils/config-file' + +/** + * Handles the workflow saving process by displaying a save dialog to the user, allowing them to select a location to save the workflow file. + * + * @param data - The workflow data to be saved. + * @returns A Promise that resolves when the workflow file has been successfully saved, or rejects if an error occurs. + */ +export async function handleWorkflowSave(data: any): Promise { + const workspaceRootFsPath = vscode.workspace.workspaceFolders?.[0]?.uri?.path + const defaultFilePath = workspaceRootFsPath + ? vscode.Uri.joinPath( + vscode.Uri.file(workspaceRootFsPath), + '.cody', + 'workflows', + 'workflow.json' + ) + : vscode.Uri.file('workflow.json') + const result = await vscode.window.showSaveDialog({ + defaultUri: defaultFilePath, + filters: { + 'Workflow Files': ['json'], + }, + title: 'Save Workflow', + }) + if (result) { + try { + await writeToCodyJSON(result, data) + void vscode.window.showInformationMessage('Workflow saved successfully!') + } catch (error) { + void vscode.window.showErrorMessage(`Failed to save workflow: ${error}`) + } + } +} + +/** + * Handles the workflow loading process by displaying an open dialog to the user, allowing them to select a workflow file. + * + * @returns The loaded workflow data, or `null` if the user cancels the operation or an error occurs. + */ +export async function handleWorkflowLoad(): Promise { + const workspaceRootFsPath = vscode.workspace.workspaceFolders?.[0]?.uri?.path + const defaultFilePath = workspaceRootFsPath + ? vscode.Uri.joinPath( + vscode.Uri.file(workspaceRootFsPath), + '.cody', + 'workflows', + 'workflow.json' + ) + : vscode.Uri.file('workflow.json') + + const result = await vscode.window.showOpenDialog({ + defaultUri: defaultFilePath, + canSelectMany: false, + filters: { + 'Workflow Files': ['json'], + }, + title: 'Load Workflow', + }) + + if (result?.[0]) { + try { + const content = await vscode.workspace.fs.readFile(result[0]) + const data = JSON.parse(content.toString()) + void vscode.window.showInformationMessage('Workflow loaded successfully!') + return data + } catch (error) { + void vscode.window.showErrorMessage(`Failed to load workflow: ${error}`) + return null + } + } + return null +} diff --git a/vscode/src/workflow/workflow.ts b/vscode/src/workflow/workflow.ts new file mode 100644 index 000000000000..03af5ce14a02 --- /dev/null +++ b/vscode/src/workflow/workflow.ts @@ -0,0 +1,88 @@ +import * as vscode from 'vscode' +import type { + WorkflowFromExtension, + WorkflowToExtension, +} from '../../webviews/workflow/services/WorkflowProtocol' + +import type { ChatClient } from '@sourcegraph/cody-shared' +import { executeWorkflow } from './workflow-executor' +import { handleWorkflowLoad, handleWorkflowSave } from './workflow-io' + +/** + * Registers the Cody workflow commands in the Visual Studio Code extension context. + * + * This function sets up the necessary event handlers and message handlers for the Cody workflow editor webview panel. + * It allows users to open the workflow editor, save the current workflow, load a previously saved workflow, and execute the current workflow. + * + * @param context - The Visual Studio Code extension context. + * @param chatClient - The Cody chat client for executing the workflow. + * @returns void + */ +export function registerWorkflowCommands(context: vscode.ExtensionContext, chatClient: ChatClient) { + context.subscriptions.push( + vscode.commands.registerCommand('cody.openWorkflowEditor', async () => { + const panel = vscode.window.createWebviewPanel( + 'codyWorkflow', + 'Cody Workflow Editor', + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'dist')], + } + ) + + // Handler for message from the webview + panel.webview.onDidReceiveMessage( + async (message: WorkflowToExtension) => { + switch (message.type) { + case 'save_workflow': + await handleWorkflowSave(message.data) + break + case 'load_workflow': { + const loadedData = await handleWorkflowLoad() + if (loadedData) { + panel.webview.postMessage({ + type: 'workflow_loaded', + data: loadedData, + } as WorkflowFromExtension) + } + break + } + case 'execute_workflow': { + if (message.data?.nodes && message.data?.edges) { + await executeWorkflow( + message.data.nodes, + message.data.edges, + panel.webview, + chatClient + ) + } + break + } + } + }, + undefined, + context.subscriptions + ) + + // Clean Up + panel.onDidDispose(() => { + panel.dispose() + }) + + const webviewPath = vscode.Uri.joinPath(context.extensionUri, 'dist/webviews') + + // Read the HTML file content + const root = vscode.Uri.joinPath(webviewPath, 'workflow.html') + const bytes = await vscode.workspace.fs.readFile(root) + const decoded = new TextDecoder('utf-8').decode(bytes) + const resources = panel.webview.asWebviewUri(webviewPath) + + // Replace variables in the HTML content + panel.webview.html = decoded + .replaceAll('./', `${resources.toString()}/`) + .replaceAll('{cspSource}', panel.webview.cspSource) + }) + ) +} diff --git a/vscode/webviews/CodyPanel.tsx b/vscode/webviews/CodyPanel.tsx index c0a8c80f6e12..601031dab7e4 100644 --- a/vscode/webviews/CodyPanel.tsx +++ b/vscode/webviews/CodyPanel.tsx @@ -15,9 +15,9 @@ import { Notices } from './components/Notices' import { StateDebugOverlay } from './components/StateDebugOverlay' import { TabContainer, TabRoot } from './components/shadcn/ui/tabs' import { AccountTab, HistoryTab, PromptsTab, SettingsTab, TabsBar, View } from './tabs' +import ToolboxTab from './tabs/ToolboxTab' import { useFeatureFlag } from './utils/useFeatureFlags' import { TabViewContext } from './utils/useTabView' - /** * The Cody tab panel, with tabs for chat, history, prompts, etc. */ @@ -128,6 +128,9 @@ export const CodyPanel: FunctionComponent< {view === View.Prompts && ( )} + {view === View.Toolbox && config.webviewType === 'sidebar' && ( + + )} {view === View.Account && } {view === View.Settings && } diff --git a/vscode/webviews/components/shadcn/ui/input.tsx b/vscode/webviews/components/shadcn/ui/input.tsx new file mode 100644 index 000000000000..311673667a68 --- /dev/null +++ b/vscode/webviews/components/shadcn/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import { cn } from '../utils' + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ) +}) +Input.displayName = 'Input' + +export { Input } diff --git a/vscode/webviews/components/shadcn/ui/label.tsx b/vscode/webviews/components/shadcn/ui/label.tsx new file mode 100644 index 000000000000..fef11caae451 --- /dev/null +++ b/vscode/webviews/components/shadcn/ui/label.tsx @@ -0,0 +1,21 @@ +import * as LabelPrimitive from '@radix-ui/react-label' +import * as React from 'react' +import { cn } from '../utils' + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/vscode/webviews/components/shadcn/ui/textarea.tsx b/vscode/webviews/components/shadcn/ui/textarea.tsx new file mode 100644 index 000000000000..40d933b20ee4 --- /dev/null +++ b/vscode/webviews/components/shadcn/ui/textarea.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import { cn } from '../utils' + +export interface TextareaProps extends React.TextareaHTMLAttributes {} + +export const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +