Compare commits

...

42 Commits

Author SHA1 Message Date
Siddharth Ganesan
f553667242 Lint 2025-07-08 11:24:14 -07:00
Siddharth Ganesan
0c753c4394 It works? 2025-07-08 11:23:52 -07:00
Siddharth Ganesan
2ac203e233 Lint :( 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
d29692ede4 Add logs 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
f60232fa5b Endpoint deployment 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
4ceec7ff9a Endpoitn checkpoint 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
0ff86a1413 Endpoint stuff v3 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
4886e5aae8 Endpoint deployment v1 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
6274bdcb18 Better logging 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
7064f69520 Make lambda fields required 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
154d8a674a Reorder fields in block 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
a6e144ad93 E2E lambda deployment using agent without tool calls 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
d0514a39a8 Fix switch statement 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
ee66cd262b Abstract timeout and memory 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
5aab24e1ed Abstract timeout and memory 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
689d88fd7e Route updates 2025-07-07 22:19:45 -07:00
Siddharth Ganesan
b1047503b9 Change code input to json 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
ec1eec4546 Add downstream tool options 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
2b3989edd2 Make codefiles visible downstream 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
cb393c1638 Update block 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
c82e5ac3b3 Update fetch 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
67030d9576 Fetch v1 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
8c157083bc Checkpoint 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
6f07c2958e Clean up param validation 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
be100e4f86 Initial version of lambda works 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
46be9e3558 Initial version of lambda creation works 2025-07-07 22:19:44 -07:00
Siddharth Ganesan
abf1ac06ce Add initial aws lambda ui block 2025-07-07 22:19:44 -07:00
Vikhyath Mondreti
3e45d793f1 fix(revert-deployed): correctly revert to deployed state as unit op using separate endpoint (#633)
* fix(revert-deployed): revert deployed functionality with separate endpoint

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
2025-07-07 22:16:17 -07:00
Vikhyath Mondreti
5167deb75c fix(resp format): non-json input was crashing (#631)
* fix response format non-json input crash bug

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
2025-07-07 20:03:01 -07:00
Waleed Latif
02b7899861 fix(docs): fixed broken docs links (#632) 2025-07-07 19:59:30 -07:00
Waleed Latif
7e4669108f feat(build): added turbopack builds to prod (#630)
* added turbopack to prod builds

* block access to sourcemaps

* revert changes to docs
2025-07-07 19:51:39 -07:00
Adam Gough
ede224a15f fix(mem-deletion): hard deletion of memory (#622)
* fix: memory deletion

* fix: bun run lint

---------

Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
2025-07-07 19:28:28 -07:00
Vikhyath Mondreti
5cf7d025db fix(oauth): fix oauth to use correct subblock value setter + remove unused local storage code (#628)
* fix(oauth): fixed oauth state not persisting in credential selector

* remove unused local storage code for oauth

* fix lint

* selector clearance issue fix

* fix typing issue

* fix lint

* remove cred id from logs

* fix lint

* works

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
2025-07-07 18:40:33 -07:00
Waleed Latif
b4eda8fe6a feat(tools): added reordering of tool calls in agent tool input (#629)
* added tool re-ordering in agent block

* styling
2025-07-07 17:25:51 -07:00
Vikhyath Mondreti
60e2e6c735 fix(reddit): update to oauth endpoints (#627)
* fix(reddit): change tool to use oauth token

* fix lint

* add contact info

* Update apps/sim/tools/reddit/get_comments.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/sim/tools/reddit/hot_posts.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/sim/tools/reddit/get_posts.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix type error

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-07-07 13:32:23 -07:00
Vikhyath Mondreti
c635b19548 fix(frozen canvas): don't error if workflow state not available for migrated logs (#624)
* fix(frozen canvas): don't error if workflow state not available for old logs

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
2025-07-07 02:34:49 -07:00
Vikhyath Mondreti
0bf9ce0b9e feat(enhanced logs): integration + log visualizer canvas (#618)
* feat(logs): enhanced logging system with cleanup and theme fixes

- Implement enhanced logging cleanup with S3 archival and retention policies
- Fix error propagation in trace spans for manual executions
- Add theme-aware styling for frozen canvas modal
- Integrate enhanced logging system across all execution pathways
- Add comprehensive trace span processing and iteration navigation
- Fix boolean parameter types in enhanced logs API

* add warning for old logs

* fix lint

* added cost for streaming outputs

* fix overflow issue

* fix lint

* fix selection on closing sidebar

* tooltips z index increase

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2025-07-06 20:01:28 -07:00
Aditya Tripathi
e22f0123a3 fix(envvars): t3-env standardization (#606)
* chore: use t3-env as source of truth

* chore: update mock env for failing tests
2025-07-06 20:01:28 -07:00
Vikhyath Mondreti
231bfb9add fix(deletions): folder deletions were hanging + use cascade deletions throughout (#620)
* use cascade deletion

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
2025-07-06 20:01:28 -07:00
Waleed Latif
cac9ad250d fix(sharing): fixed folders not appearing when sharing workflows (#616)
* fix(sharing): fixed folders not appearing when sharing workflows

* cleanup

* fixed error case
2025-07-06 20:01:28 -07:00
Vikhyath Mondreti
78b5ae7b3d v0.2.7: fix + feat (#615)
* feat(logging): add additional logs for proxy routes

* fix(blocks): workflow handler not working outside gui (#609)

* fix: key to call api internally for workflow block

* feat: use jwt for internal auth to avoid a static key

* chore: formatter

* fix(sidebar): added loop & parallel subblcoks to sidebar search

* merged improvement/connection into staging (#604)

* merged improvement/connection into staging

* fix: merge conflicts and improved block path calculation

* fix: removed migration

* fix: removed duplicate call

* fix: resolver and merge conflicts

* fix: knowledge base folder

* fix: settings modal

* fix: typeform block

* fix: parallel handler

* fix: stores index

* fix: tests

* fix: tag-dropdown

* improvement: start block input and tag dropdown

* fix block id resolution + missing bracket

* fix lint

* fix test

* works

* fix

* fix lint

* Revert "fix lint"

This reverts commit 433e2f9cfc.

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>

* fix(autopan): migration missing (#614)

* add autopan migration

* fix lint

* fix linter

* fix tests

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
2025-07-04 13:48:17 -07:00
Vikhyath Mondreti
016cd6750c v0.2.6: fix + feat + improvement (#612)
* feat(function): added more granular error logs for function execution for easier debugging (#593)

* added more granular error logs for function execution

* added tests

* fixed syntax error reporting

* feat(models): added temp controls for gpt-4.1 family of models (#594)

* improvement(knowledge-upload): create and upload document to KB (#579)

* improvement: added knowledge upload

* improvement: added greptile comments (#579)

* improvement: changed to text to doc (#579)

* improvement: removed comment (#579)

* added input validation, tested persistence of KB selector

* update docs

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Waleed Latif <walif6@gmail.com>

* fix(remove workflow.state usage): no more usage of deprecated state column in any routes (#586)

* fix(remove workflow.state usage): no more usage of deprecated state col in routes

* fix lint

* fix chat route to only use deployed state

* fix lint

* better typing

* remove useless logs

* fix lint

* restore workflow handler file

* removed all other usages of deprecated 'state' column from workflows table, updated tests

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>

* fix(doc-selector-kb): enable doc selector when kb is selected (#596)

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>

* fix(unload): remove beforeunload warning since we communicate via wss (#597)

* fix(executor): fix dependency resolution, allow blocks with multiple inputs to execute (#598)

* feat(billing): added migrations for usage-based billing (#601)

* feat(billing): added migrations for usage-based billing

* lint

* lint

* feat(logging): add new schemas + types for new logging system (#599)

* feat(logging): add new schemas + types for logging

* fix lint

* update migration

* fix lint

* Remove migration 48 to avoid conflict with staging

* fixed merge conflict

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>

* fix(createWorkflow): cleanup create workflow to prevent re-renders (#607)

* fix(createWorkflow): no more client side id, duplicate schedules calls

* fix lint

* more cleanup

* fix lint

* fix spamming of create button causing issues

* fix lint

* add more colors + default workflow name changed

* Update apps/sim/stores/workflows/registry/utils.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(telegram): added markdown text rendering (#611)

* fix: added proper markdown

* fix: reverted route.ts file

---------

Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>

* fix(kb-upload): fix and consolidate KB file uploads logic (#610)

* fix(kb-upload): fix and consolidate logic

* fix lint

* consolidated presigned routes, fixed temp id kb store issue, added nav to next/prev chunk on edit chunk modal

* fix ci test

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
Co-authored-by: Waleed Latif <walif6@gmail.com>

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
2025-07-03 12:53:14 -07:00
276 changed files with 38557 additions and 5224 deletions

View File

@@ -66,17 +66,17 @@ Define the data to pass to the child workflow:
- **Single Variable Input**: Select a variable or block output to pass to the child workflow - **Single Variable Input**: Select a variable or block output to pass to the child workflow
- **Variable References**: Use `<variable.name>` to reference workflow variables - **Variable References**: Use `<variable.name>` to reference workflow variables
- **Block References**: Use `<blockName.response.field>` to reference outputs from previous blocks - **Block References**: Use `<blockName.field>` to reference outputs from previous blocks
- **Automatic Mapping**: The selected data is automatically available as `start.response.input` in the child workflow - **Automatic Mapping**: The selected data is automatically available as `start.input` in the child workflow
- **Optional**: The input field is optional - child workflows can run without input data - **Optional**: The input field is optional - child workflows can run without input data
- **Type Preservation**: Variable types (strings, numbers, objects, etc.) are preserved when passed to the child workflow - **Type Preservation**: Variable types (strings, numbers, objects, etc.) are preserved when passed to the child workflow
### Examples of Input References ### Examples of Input References
- `<variable.customerData>` - Pass a workflow variable - `<variable.customerData>` - Pass a workflow variable
- `<dataProcessor.response.result>` - Pass the result from a previous block - `<dataProcessor.result>` - Pass the result from a previous block
- `<start.response.input>` - Pass the original workflow input - `<start.input>` - Pass the original workflow input
- `<apiCall.response.data.user>` - Pass a specific field from an API response - `<apiCall.data.user>` - Pass a specific field from an API response
### Execution Context ### Execution Context
@@ -109,7 +109,7 @@ To prevent infinite recursion and ensure system stability, the Workflow block in
<strong>Workflow ID</strong>: The identifier of the workflow to execute <strong>Workflow ID</strong>: The identifier of the workflow to execute
</li> </li>
<li> <li>
<strong>Input Variable</strong>: Variable or block reference to pass to the child workflow (e.g., `<variable.name>` or `<block.response.field>`) <strong>Input Variable</strong>: Variable or block reference to pass to the child workflow (e.g., `<variable.name>` or `<block.field>`)
</li> </li>
</ul> </ul>
</Tab> </Tab>
@@ -150,23 +150,23 @@ blocks:
- type: workflow - type: workflow
name: "Setup Customer Account" name: "Setup Customer Account"
workflowId: "account-setup-workflow" workflowId: "account-setup-workflow"
input: "<Validate Customer Data.response.result>" input: "<Validate Customer Data.result>"
- type: workflow - type: workflow
name: "Send Welcome Email" name: "Send Welcome Email"
workflowId: "welcome-email-workflow" workflowId: "welcome-email-workflow"
input: "<Setup Customer Account.response.result.accountDetails>" input: "<Setup Customer Account.result.accountDetails>"
``` ```
### Child Workflow: Customer Validation ### Child Workflow: Customer Validation
```yaml ```yaml
# Reusable customer validation workflow # Reusable customer validation workflow
# Access the input data using: start.response.input # Access the input data using: start.input
blocks: blocks:
- type: function - type: function
name: "Validate Email" name: "Validate Email"
code: | code: |
const customerData = start.response.input; const customerData = start.input;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(customerData.email); return emailRegex.test(customerData.email);
@@ -174,7 +174,7 @@ blocks:
name: "Check Credit Score" name: "Check Credit Score"
url: "https://api.creditcheck.com/score" url: "https://api.creditcheck.com/score"
method: "POST" method: "POST"
body: "<start.response.input>" body: "<start.input>"
``` ```
### Variable Reference Examples ### Variable Reference Examples
@@ -184,13 +184,13 @@ blocks:
input: "<variable.customerInfo>" input: "<variable.customerInfo>"
# Using block outputs # Using block outputs
input: "<dataProcessor.response.cleanedData>" input: "<dataProcessor.cleanedData>"
# Using nested object properties # Using nested object properties
input: "<apiCall.response.data.user.profile>" input: "<apiCall.data.user.profile>"
# Using array elements (if supported by the resolver) # Using array elements (if supported by the resolver)
input: "<listProcessor.response.items[0]>" input: "<listProcessor.items[0]>"
``` ```
## Access Control and Permissions ## Access Control and Permissions

View File

@@ -81,4 +81,4 @@ Sim Studio provides a wide range of features designed to accelerate your develop
## ##
Ready to get started? Check out our [Getting Started](/getting-started) guide or explore our [Blocks](/docs/blocks) and [Tools](/docs/tools) in more detail. Ready to get started? Check out our [Getting Started](/getting-started) guide or explore our [Blocks](/blocks) and [Tools](/tools) in more detail.

View File

@@ -19,7 +19,7 @@
"fumadocs-mdx": "^11.5.6", "fumadocs-mdx": "^11.5.6",
"fumadocs-ui": "^15.0.16", "fumadocs-ui": "^15.0.16",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.2.3", "next": "^15.3.2",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",

View File

@@ -93,7 +93,7 @@ export const sampleWorkflowState = {
webhookPath: { id: 'webhookPath', type: 'short-input', value: '' }, webhookPath: { id: 'webhookPath', type: 'short-input', value: '' },
}, },
outputs: { outputs: {
response: { type: { input: 'any' } }, input: 'any',
}, },
enabled: true, enabled: true,
horizontalHandles: true, horizontalHandles: true,
@@ -111,7 +111,7 @@ export const sampleWorkflowState = {
type: 'long-input', type: 'long-input',
value: 'You are a helpful assistant', value: 'You are a helpful assistant',
}, },
context: { id: 'context', type: 'short-input', value: '<start.response.input>' }, context: { id: 'context', type: 'short-input', value: '<start.input>' },
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' }, model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
apiKey: { id: 'apiKey', type: 'short-input', value: '{{OPENAI_API_KEY}}' }, apiKey: { id: 'apiKey', type: 'short-input', value: '{{OPENAI_API_KEY}}' },
}, },
@@ -138,6 +138,7 @@ export const sampleWorkflowState = {
}, },
], ],
loops: {}, loops: {},
parallels: {},
lastSaved: Date.now(), lastSaved: Date.now(),
isDeployed: false, isDeployed: false,
} }
@@ -764,6 +765,20 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions =
bucket: 'test-s3-bucket', bucket: 'test-s3-bucket',
region: 'us-east-1', region: 'us-east-1',
}, },
S3_KB_CONFIG: {
bucket: 'test-s3-kb-bucket',
region: 'us-east-1',
},
BLOB_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
containerName: 'test-container',
},
BLOB_KB_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
containerName: 'test-kb-container',
},
})) }))
vi.doMock('@aws-sdk/client-s3', () => ({ vi.doMock('@aws-sdk/client-s3', () => ({
@@ -806,6 +821,11 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions =
accountKey: 'testkey', accountKey: 'testkey',
containerName: 'test-container', containerName: 'test-container',
}, },
BLOB_KB_CONFIG: {
accountName: 'testaccount',
accountKey: 'testkey',
containerName: 'test-kb-container',
},
})) }))
vi.doMock('@azure/storage-blob', () => ({ vi.doMock('@azure/storage-blob', () => ({

View File

@@ -14,6 +14,8 @@ const logger = createLogger('OAuthTokenAPI')
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8) const requestId = crypto.randomUUID().slice(0, 8)
logger.info(`[${requestId}] OAuth token API POST request received`)
try { try {
// Parse request body // Parse request body
const body = await request.json() const body = await request.json()
@@ -38,6 +40,7 @@ export async function POST(request: NextRequest) {
const credential = await getCredential(requestId, credentialId, userId) const credential = await getCredential(requestId, credentialId, userId)
if (!credential) { if (!credential) {
logger.error(`[${requestId}] Credential not found: ${credentialId}`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
@@ -45,7 +48,8 @@ export async function POST(request: NextRequest) {
// Refresh the token if needed // Refresh the token if needed
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
return NextResponse.json({ accessToken }, { status: 200 }) return NextResponse.json({ accessToken }, { status: 200 })
} catch (_error) { } catch (error) {
logger.error(`[${requestId}] Failed to refresh access token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 }) return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
} }
} catch (error) { } catch (error) {

View File

@@ -89,6 +89,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
// Check if the token is expired and needs refreshing // Check if the token is expired and needs refreshing
const now = new Date() const now = new Date()
const tokenExpiry = credential.accessTokenExpiresAt const tokenExpiry = credential.accessTokenExpiresAt
// Only refresh if we have an expiration time AND it's expired AND we have a refresh token
const needsRefresh = tokenExpiry && tokenExpiry < now && !!credential.refreshToken const needsRefresh = tokenExpiry && tokenExpiry < now && !!credential.refreshToken
if (needsRefresh) { if (needsRefresh) {
@@ -166,7 +167,9 @@ export async function refreshAccessTokenIfNeeded(
// Check if we need to refresh the token // Check if we need to refresh the token
const expiresAt = credential.accessTokenExpiresAt const expiresAt = credential.accessTokenExpiresAt
const now = new Date() const now = new Date()
const needsRefresh = !expiresAt || expiresAt <= now // Only refresh if we have an expiration time AND it's expired
// If no expiration time is set (newly created credentials), assume token is valid
const needsRefresh = expiresAt && expiresAt <= now
const accessToken = credential.accessToken const accessToken = credential.accessToken
@@ -233,7 +236,9 @@ export async function refreshTokenIfNeeded(
// Check if we need to refresh the token // Check if we need to refresh the token
const expiresAt = credential.accessTokenExpiresAt const expiresAt = credential.accessTokenExpiresAt
const now = new Date() const now = new Date()
const needsRefresh = !expiresAt || expiresAt <= now // Only refresh if we have an expiration time AND it's expired
// If no expiration time is set (newly created credentials), assume token is valid
const needsRefresh = expiresAt && expiresAt <= now
// If token is still valid, return it directly // If token is still valid, return it directly
if (!needsRefresh || !credential.refreshToken) { if (!needsRefresh || !credential.refreshToken) {

View File

@@ -241,7 +241,7 @@ describe('Chat Subdomain API Route', () => {
}) })
describe('POST endpoint', () => { describe('POST endpoint', () => {
it('should handle authentication requests without messages', async () => { it('should handle authentication requests without input', async () => {
const req = createMockRequest('POST', { password: 'test-password' }) const req = createMockRequest('POST', { password: 'test-password' })
const params = Promise.resolve({ subdomain: 'password-protected-chat' }) const params = Promise.resolve({ subdomain: 'password-protected-chat' })
@@ -257,7 +257,7 @@ describe('Chat Subdomain API Route', () => {
expect(mockSetChatAuthCookie).toHaveBeenCalled() expect(mockSetChatAuthCookie).toHaveBeenCalled()
}) })
it('should return 400 for requests without message', async () => { it('should return 400 for requests without input', async () => {
const req = createMockRequest('POST', {}) const req = createMockRequest('POST', {})
const params = Promise.resolve({ subdomain: 'test-chat' }) const params = Promise.resolve({ subdomain: 'test-chat' })
@@ -269,7 +269,7 @@ describe('Chat Subdomain API Route', () => {
const data = await response.json() const data = await response.json()
expect(data).toHaveProperty('error') expect(data).toHaveProperty('error')
expect(data).toHaveProperty('message', 'No message provided') expect(data).toHaveProperty('message', 'No input provided')
}) })
it('should return 401 for unauthorized access', async () => { it('should return 401 for unauthorized access', async () => {
@@ -279,7 +279,7 @@ describe('Chat Subdomain API Route', () => {
error: 'Authentication required', error: 'Authentication required',
})) }))
const req = createMockRequest('POST', { message: 'Hello' }) const req = createMockRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ subdomain: 'protected-chat' }) const params = Promise.resolve({ subdomain: 'protected-chat' })
const { POST } = await import('./route') const { POST } = await import('./route')
@@ -342,7 +342,7 @@ describe('Chat Subdomain API Route', () => {
} }
}) })
const req = createMockRequest('POST', { message: 'Hello' }) const req = createMockRequest('POST', { input: 'Hello' })
const params = Promise.resolve({ subdomain: 'test-chat' }) const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route') const { POST } = await import('./route')
@@ -357,7 +357,7 @@ describe('Chat Subdomain API Route', () => {
}) })
it('should return streaming response for valid chat messages', async () => { it('should return streaming response for valid chat messages', async () => {
const req = createMockRequest('POST', { message: 'Hello world', conversationId: 'conv-123' }) const req = createMockRequest('POST', { input: 'Hello world', conversationId: 'conv-123' })
const params = Promise.resolve({ subdomain: 'test-chat' }) const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route') const { POST } = await import('./route')
@@ -374,7 +374,7 @@ describe('Chat Subdomain API Route', () => {
}) })
it('should handle streaming response body correctly', async () => { it('should handle streaming response body correctly', async () => {
const req = createMockRequest('POST', { message: 'Hello world' }) const req = createMockRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ subdomain: 'test-chat' }) const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route') const { POST } = await import('./route')
@@ -404,7 +404,7 @@ describe('Chat Subdomain API Route', () => {
throw new Error('Execution failed') throw new Error('Execution failed')
}) })
const req = createMockRequest('POST', { message: 'Trigger error' }) const req = createMockRequest('POST', { input: 'Trigger error' })
const params = Promise.resolve({ subdomain: 'test-chat' }) const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route') const { POST } = await import('./route')
@@ -444,7 +444,7 @@ describe('Chat Subdomain API Route', () => {
it('should pass conversationId to executeWorkflowForChat when provided', async () => { it('should pass conversationId to executeWorkflowForChat when provided', async () => {
const req = createMockRequest('POST', { const req = createMockRequest('POST', {
message: 'Hello world', input: 'Hello world',
conversationId: 'test-conversation-123', conversationId: 'test-conversation-123',
}) })
const params = Promise.resolve({ subdomain: 'test-chat' }) const params = Promise.resolve({ subdomain: 'test-chat' })
@@ -461,7 +461,7 @@ describe('Chat Subdomain API Route', () => {
}) })
it('should handle missing conversationId gracefully', async () => { it('should handle missing conversationId gracefully', async () => {
const req = createMockRequest('POST', { message: 'Hello world' }) const req = createMockRequest('POST', { input: 'Hello world' })
const params = Promise.resolve({ subdomain: 'test-chat' }) const params = Promise.resolve({ subdomain: 'test-chat' })
const { POST } = await import('./route') const { POST } = await import('./route')

View File

@@ -72,11 +72,11 @@ export async function POST(
} }
// Use the already parsed body // Use the already parsed body
const { message, password, email, conversationId } = parsedBody const { input, password, email, conversationId } = parsedBody
// If this is an authentication request (has password or email but no message), // If this is an authentication request (has password or email but no input),
// set auth cookie and return success // set auth cookie and return success
if ((password || email) && !message) { if ((password || email) && !input) {
const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
// Set authentication cookie // Set authentication cookie
@@ -86,8 +86,8 @@ export async function POST(
} }
// For chat messages, create regular response // For chat messages, create regular response
if (!message) { if (!input) {
return addCorsHeaders(createErrorResponse('No message provided', 400), request) return addCorsHeaders(createErrorResponse('No input provided', 400), request)
} }
// Get the workflow for this chat // Get the workflow for this chat
@@ -105,8 +105,8 @@ export async function POST(
} }
try { try {
// Execute workflow with structured input (message + conversationId for context) // Execute workflow with structured input (input + conversationId for context)
const result = await executeWorkflowForChat(deployment.id, message, conversationId) const result = await executeWorkflowForChat(deployment.id, input, conversationId)
// The result is always a ReadableStream that we can pipe to the client // The result is always a ReadableStream that we can pipe to the client
const streamResponse = new NextResponse(result, { const streamResponse = new NextResponse(result, {

View File

@@ -3,8 +3,9 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { persistExecutionLogs } from '@/lib/logs/execution-logger' import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans' import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import { decryptSecret } from '@/lib/utils' import { decryptSecret } from '@/lib/utils'
import { db } from '@/db' import { db } from '@/db'
import { chat, environment as envTable, userStats, workflow } from '@/db/schema' import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
@@ -128,10 +129,10 @@ export async function validateChatAuth(
return { authorized: false, error: 'Password is required' } return { authorized: false, error: 'Password is required' }
} }
const { password, message } = parsedBody const { password, input } = parsedBody
// If this is a chat message, not an auth attempt // If this is a chat message, not an auth attempt
if (message && !password) { if (input && !password) {
return { authorized: false, error: 'auth_required_password' } return { authorized: false, error: 'auth_required_password' }
} }
@@ -170,10 +171,10 @@ export async function validateChatAuth(
return { authorized: false, error: 'Email is required' } return { authorized: false, error: 'Email is required' }
} }
const { email, message } = parsedBody const { email, input } = parsedBody
// If this is a chat message, not an auth attempt // If this is a chat message, not an auth attempt
if (message && !email) { if (input && !email) {
return { authorized: false, error: 'auth_required_email' } return { authorized: false, error: 'auth_required_email' }
} }
@@ -211,17 +212,17 @@ export async function validateChatAuth(
/** /**
* Executes a workflow for a chat request and returns the formatted output. * Executes a workflow for a chat request and returns the formatted output.
* *
* When workflows reference <start.response.input>, they receive a structured JSON * When workflows reference <start.input>, they receive the input directly.
* containing both the message and conversationId for maintaining chat context. * The conversationId is available at <start.conversationId> for maintaining chat context.
* *
* @param chatId - Chat deployment identifier * @param chatId - Chat deployment identifier
* @param message - User's chat message * @param input - User's chat input
* @param conversationId - Optional ID for maintaining conversation context * @param conversationId - Optional ID for maintaining conversation context
* @returns Workflow execution result formatted for the chat interface * @returns Workflow execution result formatted for the chat interface
*/ */
export async function executeWorkflowForChat( export async function executeWorkflowForChat(
chatId: string, chatId: string,
message: string, input: string,
conversationId?: string conversationId?: string
): Promise<any> { ): Promise<any> {
const requestId = crypto.randomUUID().slice(0, 8) const requestId = crypto.randomUUID().slice(0, 8)
@@ -252,11 +253,14 @@ export async function executeWorkflowForChat(
const deployment = deploymentResult[0] const deployment = deploymentResult[0]
const workflowId = deployment.workflowId const workflowId = deployment.workflowId
const executionId = uuidv4()
// Set up enhanced logging for chat execution
const loggingSession = new EnhancedLoggingSession(workflowId, executionId, 'chat', requestId)
// Check for multi-output configuration in customizations // Check for multi-output configuration in customizations
const customizations = (deployment.customizations || {}) as Record<string, any> const customizations = (deployment.customizations || {}) as Record<string, any>
let outputBlockIds: string[] = [] let outputBlockIds: string[] = []
let outputPaths: string[] = []
// Extract output configs from the new schema format // Extract output configs from the new schema format
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) { if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
@@ -271,13 +275,11 @@ export async function executeWorkflowForChat(
}) })
outputBlockIds = deployment.outputConfigs.map((config) => config.blockId) outputBlockIds = deployment.outputConfigs.map((config) => config.blockId)
outputPaths = deployment.outputConfigs.map((config) => config.path || '')
} else { } else {
// Use customizations as fallback // Use customizations as fallback
outputBlockIds = Array.isArray(customizations.outputBlockIds) outputBlockIds = Array.isArray(customizations.outputBlockIds)
? customizations.outputBlockIds ? customizations.outputBlockIds
: [] : []
outputPaths = Array.isArray(customizations.outputPaths) ? customizations.outputPaths : []
} }
// Fall back to customizations if we still have no outputs // Fall back to customizations if we still have no outputs
@@ -287,7 +289,6 @@ export async function executeWorkflowForChat(
customizations.outputBlockIds.length > 0 customizations.outputBlockIds.length > 0
) { ) {
outputBlockIds = customizations.outputBlockIds outputBlockIds = customizations.outputBlockIds
outputPaths = customizations.outputPaths || new Array(outputBlockIds.length).fill('')
} }
logger.debug(`[${requestId}] Using ${outputBlockIds.length} output blocks for extraction`) logger.debug(`[${requestId}] Using ${outputBlockIds.length} output blocks for extraction`)
@@ -407,6 +408,13 @@ export async function executeWorkflowForChat(
{} as Record<string, Record<string, any>> {} as Record<string, Record<string, any>>
) )
// Start enhanced logging session
await loggingSession.safeStart({
userId: deployment.userId,
workspaceId: '', // TODO: Get from workflow
variables: workflowVariables,
})
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
const encoder = new TextEncoder() const encoder = new TextEncoder()
@@ -445,7 +453,7 @@ export async function executeWorkflowForChat(
workflow: serializedWorkflow, workflow: serializedWorkflow,
currentBlockStates: processedBlockStates, currentBlockStates: processedBlockStates,
envVarValues: decryptedEnvVars, envVarValues: decryptedEnvVars,
workflowInput: { input: message, conversationId }, workflowInput: { input: input, conversationId },
workflowVariables, workflowVariables,
contextExtensions: { contextExtensions: {
stream: true, stream: true,
@@ -458,16 +466,41 @@ export async function executeWorkflowForChat(
}, },
}) })
const result = await executor.execute(workflowId) // Set up enhanced logging on the executor
loggingSession.setupExecutor(executor)
let result
try {
result = await executor.execute(workflowId)
} catch (error: any) {
logger.error(`[${requestId}] Chat workflow execution failed:`, error)
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Chat workflow execution failed',
stackTrace: error.stack,
},
})
throw error
}
if (result && 'success' in result) { if (result && 'success' in result) {
result.logs?.forEach((log: BlockLog) => { // Update streamed content and apply tokenization
if (streamedContent.has(log.blockId)) { if (result.logs) {
if (log.output?.response) { result.logs.forEach((log: BlockLog) => {
log.output.response.content = streamedContent.get(log.blockId) if (streamedContent.has(log.blockId)) {
const content = streamedContent.get(log.blockId)
if (log.output) {
log.output.content = content
}
} }
} })
})
// Process all logs for streaming tokenization
const processedCount = processStreamingBlockLogs(result.logs, streamedContent)
logger.info(`[CHAT-API] Processed ${processedCount} blocks for streaming tokenization`)
}
const { traceSpans, totalDuration } = buildTraceSpans(result) const { traceSpans, totalDuration } = buildTraceSpans(result)
const enrichedResult = { ...result, traceSpans, totalDuration } const enrichedResult = { ...result, traceSpans, totalDuration }
@@ -481,8 +514,7 @@ export async function executeWorkflowForChat(
;(enrichedResult.metadata as any).conversationId = conversationId ;(enrichedResult.metadata as any).conversationId = conversationId
} }
const executionId = uuidv4() const executionId = uuidv4()
await persistExecutionLogs(workflowId, executionId, enrichedResult, 'chat') logger.debug(`Generated execution ID for deployed chat: ${executionId}`)
logger.debug(`Persisted logs for deployed chat: ${executionId}`)
if (result.success) { if (result.success) {
try { try {
@@ -506,6 +538,17 @@ export async function executeWorkflowForChat(
) )
} }
// Complete enhanced logging session (for both success and failure)
if (result && 'success' in result) {
const { traceSpans } = buildTraceSpans(result)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: result.metadata?.duration || 0,
finalOutput: result.output,
traceSpans,
})
}
controller.close() controller.close()
}, },
}) })

View File

@@ -239,7 +239,7 @@ Example Scenario:
User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable." User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable."
Generated Code: Generated Code:
const userId = <block.response.content>; // Correct: Accessing input parameter without quotes const userId = <block.content>; // Correct: Accessing input parameter without quotes
const apiKey = {{SERVICE_API_KEY}}; // Correct: Accessing environment variable without quotes const apiKey = {{SERVICE_API_KEY}}; // Correct: Accessing environment variable without quotes
const url = \`https://api.example.com/users/\${userId}\`; const url = \`https://api.example.com/users/\${userId}\`;
@@ -273,7 +273,7 @@ Do not include import/require statements unless absolutely necessary and they ar
Do not include markdown formatting or explanations. Do not include markdown formatting or explanations.
Output only the raw TypeScript code. Use modern TypeScript features where appropriate. Do not use semicolons. Output only the raw TypeScript code. Use modern TypeScript features where appropriate. Do not use semicolons.
Example: Example:
const userId = <block.response.content> as string const userId = <block.content> as string
const apiKey = {{SERVICE_API_KEY}} const apiKey = {{SERVICE_API_KEY}}
const response = await fetch(\`https://api.example.com/users/\${userId}\`, { headers: { Authorization: \`Bearer \${apiKey}\` } }) const response = await fetch(\`https://api.example.com/users/\${userId}\`, { headers: { Authorization: \`Bearer \${apiKey}\` } })
if (!response.ok) { if (!response.ok) {

View File

@@ -39,8 +39,9 @@ describe('/api/files/presigned', () => {
const response = await POST(request) const response = await POST(request)
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled') expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled')
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
expect(data.directUploadSupported).toBe(false) expect(data.directUploadSupported).toBe(false)
}) })
@@ -64,7 +65,8 @@ describe('/api/files/presigned', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(data.error).toBe('Missing fileName or contentType') expect(data.error).toBe('fileName is required and cannot be empty')
expect(data.code).toBe('VALIDATION_ERROR')
}) })
it('should return error when contentType is missing', async () => { it('should return error when contentType is missing', async () => {
@@ -87,7 +89,59 @@ describe('/api/files/presigned', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(data.error).toBe('Missing fileName or contentType') expect(data.error).toBe('contentType is required and cannot be empty')
expect(data.code).toBe('VALIDATION_ERROR')
})
it('should return error when fileSize is invalid', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'test.txt',
contentType: 'text/plain',
fileSize: 0,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('fileSize must be a positive number')
expect(data.code).toBe('VALIDATION_ERROR')
})
it('should return error when file size exceeds limit', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const largeFileSize = 150 * 1024 * 1024 // 150MB (exceeds 100MB limit)
const request = new NextRequest('http://localhost:3000/api/files/presigned', {
method: 'POST',
body: JSON.stringify({
fileName: 'large-file.txt',
contentType: 'text/plain',
fileSize: largeFileSize,
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('exceeds maximum allowed size')
expect(data.code).toBe('VALIDATION_ERROR')
}) })
it('should generate S3 presigned URL successfully', async () => { it('should generate S3 presigned URL successfully', async () => {
@@ -122,6 +176,34 @@ describe('/api/files/presigned', () => {
expect(data.directUploadSupported).toBe(true) expect(data.directUploadSupported).toBe(true)
}) })
it('should generate knowledge-base S3 presigned URL with kb prefix', async () => {
setupFileApiMocks({
cloudEnabled: true,
storageProvider: 's3',
})
const { POST } = await import('./route')
const request = new NextRequest(
'http://localhost:3000/api/files/presigned?type=knowledge-base',
{
method: 'POST',
body: JSON.stringify({
fileName: 'knowledge-doc.pdf',
contentType: 'application/pdf',
fileSize: 2048,
}),
}
)
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fileInfo.key).toMatch(/^kb\/.*knowledge-doc\.pdf$/)
expect(data.directUploadSupported).toBe(true)
})
it('should generate Azure Blob presigned URL successfully', async () => { it('should generate Azure Blob presigned URL successfully', async () => {
setupFileApiMocks({ setupFileApiMocks({
cloudEnabled: true, cloudEnabled: true,
@@ -182,8 +264,9 @@ describe('/api/files/presigned', () => {
const response = await POST(request) const response = await POST(request)
const data = await response.json() const data = await response.json()
expect(response.status).toBe(400) expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
expect(data.error).toBe('Unknown storage provider') expect(data.error).toBe('Unknown storage provider: unknown') // Updated error message
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
expect(data.directUploadSupported).toBe(false) expect(data.directUploadSupported).toBe(false)
}) })
@@ -225,8 +308,10 @@ describe('/api/files/presigned', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(500) expect(response.status).toBe(500)
expect(data.error).toBe('Error') expect(data.error).toBe(
expect(data.message).toBe('S3 service unavailable') 'Failed to generate S3 presigned URL - check AWS credentials and permissions'
) // Updated error message
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
}) })
it('should handle Azure Blob errors gracefully', async () => { it('should handle Azure Blob errors gracefully', async () => {
@@ -269,8 +354,8 @@ describe('/api/files/presigned', () => {
const data = await response.json() const data = await response.json()
expect(response.status).toBe(500) expect(response.status).toBe(500)
expect(data.error).toBe('Error') expect(data.error).toBe('Failed to generate Azure Blob presigned URL') // Updated error message
expect(data.message).toBe('Azure service unavailable') expect(data.code).toBe('STORAGE_CONFIG_ERROR')
}) })
it('should handle malformed JSON gracefully', async () => { it('should handle malformed JSON gracefully', async () => {
@@ -289,9 +374,9 @@ describe('/api/files/presigned', () => {
const response = await POST(request) const response = await POST(request)
const data = await response.json() const data = await response.json()
expect(response.status).toBe(500) expect(response.status).toBe(400) // Changed from 500 to 400 (ValidationError)
expect(data.error).toBe('SyntaxError') expect(data.error).toBe('Invalid JSON in request body') // Updated error message
expect(data.message).toContain('Unexpected token') expect(data.code).toBe('VALIDATION_ERROR')
}) })
}) })

View File

@@ -6,7 +6,7 @@ import { createLogger } from '@/lib/logs/console-logger'
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads' import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { getBlobServiceClient } from '@/lib/uploads/blob/blob-client' import { getBlobServiceClient } from '@/lib/uploads/blob/blob-client'
import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-client' import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-client'
import { BLOB_CONFIG, S3_CONFIG } from '@/lib/uploads/setup' import { BLOB_CONFIG, BLOB_KB_CONFIG, S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup'
import { createErrorResponse, createOptionsResponse } from '../utils' import { createErrorResponse, createOptionsResponse } from '../utils'
const logger = createLogger('PresignedUploadAPI') const logger = createLogger('PresignedUploadAPI')
@@ -17,124 +17,148 @@ interface PresignedUrlRequest {
fileSize: number fileSize: number
} }
type UploadType = 'general' | 'knowledge-base'
class PresignedUrlError extends Error {
constructor(
message: string,
public code: string,
public statusCode = 400
) {
super(message)
this.name = 'PresignedUrlError'
}
}
class StorageConfigError extends PresignedUrlError {
constructor(message: string) {
super(message, 'STORAGE_CONFIG_ERROR', 500)
}
}
class ValidationError extends PresignedUrlError {
constructor(message: string) {
super(message, 'VALIDATION_ERROR', 400)
}
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Parse the request body let data: PresignedUrlRequest
const data: PresignedUrlRequest = await request.json() try {
const { fileName, contentType, fileSize } = data data = await request.json()
} catch {
if (!fileName || !contentType) { throw new ValidationError('Invalid JSON in request body')
return NextResponse.json({ error: 'Missing fileName or contentType' }, { status: 400 })
} }
// Only proceed if cloud storage is enabled const { fileName, contentType, fileSize } = data
if (!fileName?.trim()) {
throw new ValidationError('fileName is required and cannot be empty')
}
if (!contentType?.trim()) {
throw new ValidationError('contentType is required and cannot be empty')
}
if (!fileSize || fileSize <= 0) {
throw new ValidationError('fileSize must be a positive number')
}
const MAX_FILE_SIZE = 100 * 1024 * 1024
if (fileSize > MAX_FILE_SIZE) {
throw new ValidationError(
`File size (${fileSize} bytes) exceeds maximum allowed size (${MAX_FILE_SIZE} bytes)`
)
}
const uploadTypeParam = request.nextUrl.searchParams.get('type')
const uploadType: UploadType =
uploadTypeParam === 'knowledge-base' ? 'knowledge-base' : 'general'
if (!isUsingCloudStorage()) { if (!isUsingCloudStorage()) {
return NextResponse.json( throw new StorageConfigError(
{ 'Direct uploads are only available when cloud storage is enabled'
error: 'Direct uploads are only available when cloud storage is enabled',
directUploadSupported: false,
},
{ status: 400 }
) )
} }
const storageProvider = getStorageProvider() const storageProvider = getStorageProvider()
logger.info(`Generating ${uploadType} presigned URL for ${fileName} using ${storageProvider}`)
switch (storageProvider) { switch (storageProvider) {
case 's3': case 's3':
return await handleS3PresignedUrl(fileName, contentType, fileSize) return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType)
case 'blob': case 'blob':
return await handleBlobPresignedUrl(fileName, contentType, fileSize) return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType)
default: default:
return NextResponse.json( throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`)
{
error: 'Unknown storage provider',
directUploadSupported: false,
},
{ status: 400 }
)
} }
} catch (error) { } catch (error) {
logger.error('Error generating presigned URL:', error) logger.error('Error generating presigned URL:', error)
if (error instanceof PresignedUrlError) {
return NextResponse.json(
{
error: error.message,
code: error.code,
directUploadSupported: false,
},
{ status: error.statusCode }
)
}
return createErrorResponse( return createErrorResponse(
error instanceof Error ? error : new Error('Failed to generate presigned URL') error instanceof Error ? error : new Error('Failed to generate presigned URL')
) )
} }
} }
async function handleS3PresignedUrl(fileName: string, contentType: string, fileSize: number) { async function handleS3PresignedUrl(
// Create a unique key for the file fileName: string,
const safeFileName = fileName.replace(/\s+/g, '-') contentType: string,
const uniqueKey = `${Date.now()}-${uuidv4()}-${safeFileName}` fileSize: number,
uploadType: UploadType
// Sanitize the original filename for S3 metadata to prevent header errors ) {
const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
// Create the S3 command
const command = new PutObjectCommand({
Bucket: S3_CONFIG.bucket,
Key: uniqueKey,
ContentType: contentType,
Metadata: {
originalName: sanitizedOriginalName,
uploadedAt: new Date().toISOString(),
},
})
// Generate the presigned URL
const presignedUrl = await getSignedUrl(getS3Client(), command, { expiresIn: 3600 })
// Create a path for API to serve the file
const servePath = `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}`
logger.info(`Generated presigned URL for ${fileName} (${uniqueKey})`)
return NextResponse.json({
presignedUrl,
fileInfo: {
path: servePath,
key: uniqueKey,
name: fileName,
size: fileSize,
type: contentType,
},
directUploadSupported: true,
})
}
async function handleBlobPresignedUrl(fileName: string, contentType: string, fileSize: number) {
// Create a unique key for the file
const safeFileName = fileName.replace(/\s+/g, '-')
const uniqueKey = `${Date.now()}-${uuidv4()}-${safeFileName}`
try { try {
const blobServiceClient = getBlobServiceClient() const config = uploadType === 'knowledge-base' ? S3_KB_CONFIG : S3_CONFIG
const containerClient = blobServiceClient.getContainerClient(BLOB_CONFIG.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey)
// Generate SAS token for upload (write permission) if (!config.bucket || !config.region) {
const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } = throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`)
await import('@azure/storage-blob')
const sasOptions = {
containerName: BLOB_CONFIG.containerName,
blobName: uniqueKey,
permissions: BlobSASPermissions.parse('w'), // Write permission for upload
startsOn: new Date(),
expiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour expiration
} }
const sasToken = generateBlobSASQueryParameters( const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
sasOptions, const prefix = uploadType === 'knowledge-base' ? 'kb/' : ''
new StorageSharedKeyCredential(BLOB_CONFIG.accountName, BLOB_CONFIG.accountKey || '') const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
).toString()
const presignedUrl = `${blockBlobClient.url}?${sasToken}` const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
// Create a path for API to serve the file const metadata: Record<string, string> = {
const servePath = `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}` originalName: sanitizedOriginalName,
uploadedAt: new Date().toISOString(),
}
logger.info(`Generated presigned URL for ${fileName} (${uniqueKey})`) if (uploadType === 'knowledge-base') {
metadata.purpose = 'knowledge-base'
}
const command = new PutObjectCommand({
Bucket: config.bucket,
Key: uniqueKey,
ContentType: contentType,
Metadata: metadata,
})
let presignedUrl: string
try {
presignedUrl = await getSignedUrl(getS3Client(), command, { expiresIn: 3600 })
} catch (s3Error) {
logger.error('Failed to generate S3 presigned URL:', s3Error)
throw new StorageConfigError(
'Failed to generate S3 presigned URL - check AWS credentials and permissions'
)
}
const servePath = `/api/files/serve/s3/${encodeURIComponent(uniqueKey)}`
logger.info(`Generated ${uploadType} S3 presigned URL for ${fileName} (${uniqueKey})`)
return NextResponse.json({ return NextResponse.json({
presignedUrl, presignedUrl,
@@ -146,22 +170,103 @@ async function handleBlobPresignedUrl(fileName: string, contentType: string, fil
type: contentType, type: contentType,
}, },
directUploadSupported: true, directUploadSupported: true,
uploadHeaders: {
'x-ms-blob-type': 'BlockBlob',
'x-ms-blob-content-type': contentType,
'x-ms-meta-originalname': encodeURIComponent(fileName),
'x-ms-meta-uploadedat': new Date().toISOString(),
},
}) })
} catch (error) { } catch (error) {
logger.error('Error generating Blob presigned URL:', error) if (error instanceof PresignedUrlError) {
return createErrorResponse( throw error
error instanceof Error ? error : new Error('Failed to generate Blob presigned URL') }
) logger.error('Error in S3 presigned URL generation:', error)
throw new StorageConfigError('Failed to generate S3 presigned URL')
}
}
async function handleBlobPresignedUrl(
fileName: string,
contentType: string,
fileSize: number,
uploadType: UploadType
) {
try {
const config = uploadType === 'knowledge-base' ? BLOB_KB_CONFIG : BLOB_CONFIG
if (
!config.accountName ||
!config.containerName ||
(!config.accountKey && !config.connectionString)
) {
throw new StorageConfigError(`Azure Blob configuration missing for ${uploadType} uploads`)
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const prefix = uploadType === 'knowledge-base' ? 'kb/' : ''
const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}`
const blobServiceClient = getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(config.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey)
const { BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential } =
await import('@azure/storage-blob')
const sasOptions = {
containerName: config.containerName,
blobName: uniqueKey,
permissions: BlobSASPermissions.parse('w'), // Write permission for upload
startsOn: new Date(),
expiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour expiration
}
let sasToken: string
try {
sasToken = generateBlobSASQueryParameters(
sasOptions,
new StorageSharedKeyCredential(config.accountName, config.accountKey || '')
).toString()
} catch (blobError) {
logger.error('Failed to generate Azure Blob SAS token:', blobError)
throw new StorageConfigError(
'Failed to generate Azure Blob SAS token - check Azure credentials and permissions'
)
}
const presignedUrl = `${blockBlobClient.url}?${sasToken}`
const servePath = `/api/files/serve/blob/${encodeURIComponent(uniqueKey)}`
logger.info(`Generated ${uploadType} Azure Blob presigned URL for ${fileName} (${uniqueKey})`)
const uploadHeaders: Record<string, string> = {
'x-ms-blob-type': 'BlockBlob',
'x-ms-blob-content-type': contentType,
'x-ms-meta-originalname': encodeURIComponent(fileName),
'x-ms-meta-uploadedat': new Date().toISOString(),
}
if (uploadType === 'knowledge-base') {
uploadHeaders['x-ms-meta-purpose'] = 'knowledge-base'
}
return NextResponse.json({
presignedUrl,
fileInfo: {
path: servePath,
key: uniqueKey,
name: fileName,
size: fileSize,
type: contentType,
},
directUploadSupported: true,
uploadHeaders,
})
} catch (error) {
if (error instanceof PresignedUrlError) {
throw error
}
logger.error('Error in Azure Blob presigned URL generation:', error)
throw new StorageConfigError('Failed to generate Azure Blob presigned URL')
} }
} }
// Handle preflight requests
export async function OPTIONS() { export async function OPTIONS() {
return createOptionsResponse() return createOptionsResponse()
} }

View File

@@ -1,7 +1,8 @@
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import type { NextRequest, NextResponse } from 'next/server' import type { NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { downloadFile, isUsingCloudStorage } from '@/lib/uploads' import { downloadFile, getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { BLOB_KB_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/setup'
import '@/lib/uploads/setup.server' import '@/lib/uploads/setup.server'
import { import {
@@ -16,6 +17,19 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('FilesServeAPI') const logger = createLogger('FilesServeAPI')
async function streamToBuffer(readableStream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
readableStream.on('data', (data) => {
chunks.push(data instanceof Buffer ? data : Buffer.from(data))
})
readableStream.on('end', () => {
resolve(Buffer.concat(chunks))
})
readableStream.on('error', reject)
})
}
/** /**
* Main API route handler for serving files * Main API route handler for serving files
*/ */
@@ -85,12 +99,65 @@ async function handleLocalFile(filename: string): Promise<NextResponse> {
} }
} }
async function downloadKBFile(cloudKey: string): Promise<Buffer> {
const storageProvider = getStorageProvider()
if (storageProvider === 'blob') {
logger.info(`Downloading KB file from Azure Blob Storage: ${cloudKey}`)
// Use KB-specific blob configuration
const { getBlobServiceClient } = await import('@/lib/uploads/blob/blob-client')
const blobServiceClient = getBlobServiceClient()
const containerClient = blobServiceClient.getContainerClient(BLOB_KB_CONFIG.containerName)
const blockBlobClient = containerClient.getBlockBlobClient(cloudKey)
const downloadBlockBlobResponse = await blockBlobClient.download()
if (!downloadBlockBlobResponse.readableStreamBody) {
throw new Error('Failed to get readable stream from blob download')
}
// Convert stream to buffer
return await streamToBuffer(downloadBlockBlobResponse.readableStreamBody)
}
if (storageProvider === 's3') {
logger.info(`Downloading KB file from S3: ${cloudKey}`)
// Use KB-specific S3 configuration
const { getS3Client } = await import('@/lib/uploads/s3/s3-client')
const { GetObjectCommand } = await import('@aws-sdk/client-s3')
const s3Client = getS3Client()
const command = new GetObjectCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: cloudKey,
})
const response = await s3Client.send(command)
if (!response.Body) {
throw new Error('No body in S3 response')
}
// Convert stream to buffer using the same method as the regular S3 client
const stream = response.Body as any
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => resolve(Buffer.concat(chunks)))
stream.on('error', reject)
})
}
throw new Error(`Unsupported storage provider for KB files: ${storageProvider}`)
}
/** /**
* Proxy cloud file through our server * Proxy cloud file through our server
*/ */
async function handleCloudProxy(cloudKey: string): Promise<NextResponse> { async function handleCloudProxy(cloudKey: string): Promise<NextResponse> {
try { try {
const fileBuffer = await downloadFile(cloudKey) // Check if this is a KB file (starts with 'kb/')
const isKBFile = cloudKey.startsWith('kb/')
const fileBuffer = isKBFile ? await downloadKBFile(cloudKey) : await downloadFile(cloudKey)
// Extract the original filename from the key (last part after last /) // Extract the original filename from the key (last part after last /)
const originalFilename = cloudKey.split('/').pop() || 'download' const originalFilename = cloudKey.split('/').pop() || 'download'

View File

@@ -40,6 +40,7 @@ describe('Individual Folder API Route', () => {
} }
const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth(TEST_USER) const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth(TEST_USER)
const mockGetUserEntityPermissions = vi.fn()
function createFolderDbMock(options: FolderDbMockOptions = {}) { function createFolderDbMock(options: FolderDbMockOptions = {}) {
const { const {
@@ -109,6 +110,12 @@ describe('Individual Folder API Route', () => {
vi.resetModules() vi.resetModules()
vi.clearAllMocks() vi.clearAllMocks()
setupCommonApiMocks() setupCommonApiMocks()
mockGetUserEntityPermissions.mockResolvedValue('admin')
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
}) })
afterEach(() => { afterEach(() => {
@@ -181,6 +188,72 @@ describe('Individual Folder API Route', () => {
expect(data).toHaveProperty('error', 'Unauthorized') expect(data).toHaveProperty('error', 'Unauthorized')
}) })
it('should return 403 when user has only read permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Write access required to update folders')
})
it('should allow folder update for write permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should allow folder update for admin permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('PUT', {
name: 'Updated Folder',
})
const params = Promise.resolve({ id: 'folder-1' })
const { PUT } = await import('./route')
const response = await PUT(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should return 400 when trying to set folder as its own parent', async () => { it('should return 400 when trying to set folder as its own parent', async () => {
mockAuthenticatedUser() mockAuthenticatedUser()
@@ -387,6 +460,68 @@ describe('Individual Folder API Route', () => {
expect(data).toHaveProperty('error', 'Unauthorized') expect(data).toHaveProperty('error', 'Unauthorized')
}) })
it('should return 403 when user has only read permissions for delete', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Admin access required to delete folders')
})
it('should return 403 when user has only write permissions for delete', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions (not enough for delete)
const dbMock = createFolderDbMock()
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Admin access required to delete folders')
})
it('should allow folder deletion for admin permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
const dbMock = createFolderDbMock({
folderLookupResult: mockFolder,
})
vi.doMock('@/db', () => dbMock)
const req = createMockRequest('DELETE')
const params = Promise.resolve({ id: 'folder-1' })
const { DELETE } = await import('./route')
const response = await DELETE(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('success', true)
})
it('should handle database errors during deletion', async () => { it('should handle database errors during deletion', async () => {
mockAuthenticatedUser() mockAuthenticatedUser()

View File

@@ -2,6 +2,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db' import { db } from '@/db'
import { workflow, workflowFolder } from '@/db/schema' import { workflow, workflowFolder } from '@/db/schema'
@@ -19,17 +20,31 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const body = await request.json() const body = await request.json()
const { name, color, isExpanded, parentId } = body const { name, color, isExpanded, parentId } = body
// Verify the folder exists and belongs to the user // Verify the folder exists
const existingFolder = await db const existingFolder = await db
.select() .select()
.from(workflowFolder) .from(workflowFolder)
.where(and(eq(workflowFolder.id, id), eq(workflowFolder.userId, session.user.id))) .where(eq(workflowFolder.id, id))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (!existingFolder) { if (!existingFolder) {
return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
} }
// Check if user has write permissions for the workspace
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
existingFolder.workspaceId
)
if (!workspacePermission || workspacePermission === 'read') {
return NextResponse.json(
{ error: 'Write access required to update folders' },
{ status: 403 }
)
}
// Prevent setting a folder as its own parent or creating circular references // Prevent setting a folder as its own parent or creating circular references
if (parentId && parentId === id) { if (parentId && parentId === id) {
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
@@ -81,19 +96,33 @@ export async function DELETE(
const { id } = await params const { id } = await params
// Verify the folder exists and belongs to the user // Verify the folder exists
const existingFolder = await db const existingFolder = await db
.select() .select()
.from(workflowFolder) .from(workflowFolder)
.where(and(eq(workflowFolder.id, id), eq(workflowFolder.userId, session.user.id))) .where(eq(workflowFolder.id, id))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (!existingFolder) { if (!existingFolder) {
return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
} }
// Check if user has admin permissions for the workspace (admin-only for deletions)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
existingFolder.workspaceId
)
if (workspacePermission !== 'admin') {
return NextResponse.json(
{ error: 'Admin access required to delete folders' },
{ status: 403 }
)
}
// Recursively delete folder and all its contents // Recursively delete folder and all its contents
const deletionStats = await deleteFolderRecursively(id, session.user.id) const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
logger.info('Deleted folder and all contents:', { logger.info('Deleted folder and all contents:', {
id, id,
@@ -113,41 +142,40 @@ export async function DELETE(
// Helper function to recursively delete a folder and all its contents // Helper function to recursively delete a folder and all its contents
async function deleteFolderRecursively( async function deleteFolderRecursively(
folderId: string, folderId: string,
userId: string workspaceId: string
): Promise<{ folders: number; workflows: number }> { ): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 } const stats = { folders: 0, workflows: 0 }
// Get all child folders first // Get all child folders first (workspace-scoped, not user-scoped)
const childFolders = await db const childFolders = await db
.select({ id: workflowFolder.id }) .select({ id: workflowFolder.id })
.from(workflowFolder) .from(workflowFolder)
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.userId, userId))) .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
// Recursively delete child folders // Recursively delete child folders
for (const childFolder of childFolders) { for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, userId) const childStats = await deleteFolderRecursively(childFolder.id, workspaceId)
stats.folders += childStats.folders stats.folders += childStats.folders
stats.workflows += childStats.workflows stats.workflows += childStats.workflows
} }
// Delete all workflows in this folder // Delete all workflows in this folder (workspace-scoped, not user-scoped)
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows
const workflowsInFolder = await db const workflowsInFolder = await db
.select({ id: workflow.id }) .select({ id: workflow.id })
.from(workflow) .from(workflow)
.where(and(eq(workflow.folderId, folderId), eq(workflow.userId, userId))) .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
if (workflowsInFolder.length > 0) { if (workflowsInFolder.length > 0) {
await db await db
.delete(workflow) .delete(workflow)
.where(and(eq(workflow.folderId, folderId), eq(workflow.userId, userId))) .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
stats.workflows += workflowsInFolder.length stats.workflows += workflowsInFolder.length
} }
// Delete this folder // Delete this folder
await db await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId))
.delete(workflowFolder)
.where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.userId, userId)))
stats.folders += 1 stats.folders += 1

View File

@@ -52,6 +52,7 @@ describe('Folders API Route', () => {
const mockValues = vi.fn() const mockValues = vi.fn()
const mockReturning = vi.fn() const mockReturning = vi.fn()
const mockTransaction = vi.fn() const mockTransaction = vi.fn()
const mockGetUserEntityPermissions = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.resetModules() vi.resetModules()
@@ -72,6 +73,8 @@ describe('Folders API Route', () => {
mockValues.mockReturnValue({ returning: mockReturning }) mockValues.mockReturnValue({ returning: mockReturning })
mockReturning.mockReturnValue([mockFolders[0]]) mockReturning.mockReturnValue([mockFolders[0]])
mockGetUserEntityPermissions.mockResolvedValue('admin')
vi.doMock('@/db', () => ({ vi.doMock('@/db', () => ({
db: { db: {
select: mockSelect, select: mockSelect,
@@ -79,6 +82,10 @@ describe('Folders API Route', () => {
transaction: mockTransaction, transaction: mockTransaction,
}, },
})) }))
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
}) })
afterEach(() => { afterEach(() => {
@@ -143,6 +150,42 @@ describe('Folders API Route', () => {
expect(data).toHaveProperty('error', 'Workspace ID is required') expect(data).toHaveProperty('error', 'Workspace ID is required')
}) })
it('should return 403 when user has no workspace permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue(null) // No permissions
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders?workspaceId=workspace-123',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Access denied to this workspace')
})
it('should return 403 when user has only read permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const mockRequest = createMockRequest('GET')
Object.defineProperty(mockRequest, 'url', {
value: 'http://localhost:3000/api/folders?workspaceId=workspace-123',
})
const { GET } = await import('./route')
const response = await GET(mockRequest)
expect(response.status).toBe(200) // Should work for read permissions
const data = await response.json()
expect(data).toHaveProperty('folders')
})
it('should handle database errors gracefully', async () => { it('should handle database errors gracefully', async () => {
mockAuthenticatedUser() mockAuthenticatedUser()
@@ -295,6 +338,100 @@ describe('Folders API Route', () => {
expect(data).toHaveProperty('error', 'Unauthorized') expect(data).toHaveProperty('error', 'Unauthorized')
}) })
it('should return 403 when user has only read permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('read') // Read-only permissions
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(403)
const data = await response.json()
expect(data).toHaveProperty('error', 'Write or Admin access required to create folders')
})
it('should allow folder creation for write permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should allow folder creation for admin permissions', async () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
const req = createMockRequest('POST', {
name: 'Test Folder',
workspaceId: 'workspace-123',
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
})
it('should return 400 when required fields are missing', async () => { it('should return 400 when required fields are missing', async () => {
const testCases = [ const testCases = [
{ name: '', workspaceId: 'workspace-123' }, // Missing name { name: '', workspaceId: 'workspace-123' }, // Missing name

View File

@@ -2,6 +2,7 @@ import { and, asc, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db' import { db } from '@/db'
import { workflowFolder } from '@/db/schema' import { workflowFolder } from '@/db/schema'
@@ -22,13 +23,23 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
} }
// Fetch all folders for the workspace, ordered by sortOrder and createdAt // Check if user has workspace permissions
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (!workspacePermission) {
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
}
// If user has workspace permissions, fetch ALL folders in the workspace
// This allows shared workspace members to see folders created by other users
const folders = await db const folders = await db
.select() .select()
.from(workflowFolder) .from(workflowFolder)
.where( .where(eq(workflowFolder.workspaceId, workspaceId))
and(eq(workflowFolder.workspaceId, workspaceId), eq(workflowFolder.userId, session.user.id))
)
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt))
return NextResponse.json({ folders }) return NextResponse.json({ folders })
@@ -53,19 +64,33 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 }) return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
} }
// Check if user has workspace permissions (at least 'write' access to create folders)
const workspacePermission = await getUserEntityPermissions(
session.user.id,
'workspace',
workspaceId
)
if (!workspacePermission || workspacePermission === 'read') {
return NextResponse.json(
{ error: 'Write or Admin access required to create folders' },
{ status: 403 }
)
}
// Generate a new ID // Generate a new ID
const id = crypto.randomUUID() const id = crypto.randomUUID()
// Use transaction to ensure sortOrder consistency // Use transaction to ensure sortOrder consistency
const newFolder = await db.transaction(async (tx) => { const newFolder = await db.transaction(async (tx) => {
// Get the next sort order for the parent (or root level) // Get the next sort order for the parent (or root level)
// Consider all folders in the workspace, not just those created by current user
const existingFolders = await tx const existingFolders = await tx
.select({ sortOrder: workflowFolder.sortOrder }) .select({ sortOrder: workflowFolder.sortOrder })
.from(workflowFolder) .from(workflowFolder)
.where( .where(
and( and(
eq(workflowFolder.workspaceId, workspaceId), eq(workflowFolder.workspaceId, workspaceId),
eq(workflowFolder.userId, session.user.id),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId) parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
) )
) )

View File

@@ -0,0 +1,76 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@/db/schema'
const logger = createLogger('FrozenCanvasAPI')
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ executionId: string }> }
) {
try {
const { executionId } = await params
logger.debug(`Fetching frozen canvas data for execution: ${executionId}`)
// Get the workflow execution log to find the snapshot
const [workflowLog] = await db
.select()
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
if (!workflowLog) {
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
}
// Get the workflow state snapshot
const [snapshot] = await db
.select()
.from(workflowExecutionSnapshots)
.where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId))
.limit(1)
if (!snapshot) {
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
}
const response = {
executionId,
workflowId: workflowLog.workflowId,
workflowState: snapshot.stateData,
executionMetadata: {
trigger: workflowLog.trigger,
startedAt: workflowLog.startedAt.toISOString(),
endedAt: workflowLog.endedAt?.toISOString(),
totalDurationMs: workflowLog.totalDurationMs,
blockStats: {
total: workflowLog.blockCount,
success: workflowLog.successCount,
error: workflowLog.errorCount,
skipped: workflowLog.skippedCount,
},
cost: {
total: workflowLog.totalCost ? Number.parseFloat(workflowLog.totalCost) : null,
input: workflowLog.totalInputCost ? Number.parseFloat(workflowLog.totalInputCost) : null,
output: workflowLog.totalOutputCost
? Number.parseFloat(workflowLog.totalOutputCost)
: null,
},
totalTokens: workflowLog.totalTokens,
},
}
logger.debug(`Successfully fetched frozen canvas data for execution: ${executionId}`)
logger.debug(
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
)
return NextResponse.json(response)
} catch (error) {
logger.error('Error fetching frozen canvas data:', error)
return NextResponse.json({ error: 'Failed to fetch frozen canvas data' }, { status: 500 })
}
}

View File

@@ -3,9 +3,10 @@ import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { snapshotService } from '@/lib/logs/snapshot-service'
import { getS3Client } from '@/lib/uploads/s3/s3-client' import { getS3Client } from '@/lib/uploads/s3/s3-client'
import { db } from '@/db' import { db } from '@/db'
import { subscription, user, workflow, workflowLogs } from '@/db/schema' import { subscription, user, workflow, workflowExecutionLogs } from '@/db/schema'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -66,99 +67,143 @@ export async function GET(request: Request) {
const workflowIds = workflowsQuery.map((w) => w.id) const workflowIds = workflowsQuery.map((w) => w.id)
const results = { const results = {
total: 0, enhancedLogs: {
archived: 0, total: 0,
archiveFailed: 0, archived: 0,
deleted: 0, archiveFailed: 0,
deleteFailed: 0, deleted: 0,
deleteFailed: 0,
},
snapshots: {
cleaned: 0,
cleanupFailed: 0,
},
} }
const startTime = Date.now() const startTime = Date.now()
const MAX_BATCHES = 10 const MAX_BATCHES = 10
// Process enhanced logging cleanup
let batchesProcessed = 0 let batchesProcessed = 0
let hasMoreLogs = true let hasMoreLogs = true
logger.info(`Starting enhanced logs cleanup for ${workflowIds.length} workflows`)
while (hasMoreLogs && batchesProcessed < MAX_BATCHES) { while (hasMoreLogs && batchesProcessed < MAX_BATCHES) {
const oldLogs = await db // Query enhanced execution logs that need cleanup
const oldEnhancedLogs = await db
.select({ .select({
id: workflowLogs.id, id: workflowExecutionLogs.id,
workflowId: workflowLogs.workflowId, workflowId: workflowExecutionLogs.workflowId,
executionId: workflowLogs.executionId, executionId: workflowExecutionLogs.executionId,
level: workflowLogs.level, stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
message: workflowLogs.message, level: workflowExecutionLogs.level,
duration: workflowLogs.duration, message: workflowExecutionLogs.message,
trigger: workflowLogs.trigger, trigger: workflowExecutionLogs.trigger,
createdAt: workflowLogs.createdAt, startedAt: workflowExecutionLogs.startedAt,
metadata: workflowLogs.metadata, endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
blockCount: workflowExecutionLogs.blockCount,
successCount: workflowExecutionLogs.successCount,
errorCount: workflowExecutionLogs.errorCount,
skippedCount: workflowExecutionLogs.skippedCount,
totalCost: workflowExecutionLogs.totalCost,
totalInputCost: workflowExecutionLogs.totalInputCost,
totalOutputCost: workflowExecutionLogs.totalOutputCost,
totalTokens: workflowExecutionLogs.totalTokens,
metadata: workflowExecutionLogs.metadata,
createdAt: workflowExecutionLogs.createdAt,
}) })
.from(workflowLogs) .from(workflowExecutionLogs)
.where( .where(
and( and(
inArray(workflowLogs.workflowId, workflowIds), inArray(workflowExecutionLogs.workflowId, workflowIds),
lt(workflowLogs.createdAt, retentionDate) lt(workflowExecutionLogs.createdAt, retentionDate)
) )
) )
.limit(BATCH_SIZE) .limit(BATCH_SIZE)
results.total += oldLogs.length results.enhancedLogs.total += oldEnhancedLogs.length
for (const log of oldLogs) { for (const log of oldEnhancedLogs) {
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
const logKey = `archived-logs/${today}/${log.id}.json` // Archive enhanced log with more detailed structure
const logData = JSON.stringify(log) const enhancedLogKey = `archived-enhanced-logs/${today}/${log.id}.json`
const enhancedLogData = JSON.stringify({
...log,
archivedAt: new Date().toISOString(),
logType: 'enhanced',
})
try { try {
await getS3Client().send( await getS3Client().send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: S3_CONFIG.bucket, Bucket: S3_CONFIG.bucket,
Key: logKey, Key: enhancedLogKey,
Body: logData, Body: enhancedLogData,
ContentType: 'application/json', ContentType: 'application/json',
Metadata: { Metadata: {
logId: String(log.id), logId: String(log.id),
workflowId: String(log.workflowId), workflowId: String(log.workflowId),
executionId: String(log.executionId),
logType: 'enhanced',
archivedAt: new Date().toISOString(), archivedAt: new Date().toISOString(),
}, },
}) })
) )
results.archived++ results.enhancedLogs.archived++
try { try {
// Delete enhanced log (will cascade to workflowExecutionBlocks due to foreign key)
const deleteResult = await db const deleteResult = await db
.delete(workflowLogs) .delete(workflowExecutionLogs)
.where(eq(workflowLogs.id, log.id)) .where(eq(workflowExecutionLogs.id, log.id))
.returning({ id: workflowLogs.id }) .returning({ id: workflowExecutionLogs.id })
if (deleteResult.length > 0) { if (deleteResult.length > 0) {
results.deleted++ results.enhancedLogs.deleted++
} else { } else {
results.deleteFailed++ results.enhancedLogs.deleteFailed++
logger.warn(`Failed to delete log ${log.id} after archiving: No rows deleted`) logger.warn(
`Failed to delete enhanced log ${log.id} after archiving: No rows deleted`
)
} }
} catch (deleteError) { } catch (deleteError) {
results.deleteFailed++ results.enhancedLogs.deleteFailed++
logger.error(`Error deleting log ${log.id} after archiving:`, { deleteError }) logger.error(`Error deleting enhanced log ${log.id} after archiving:`, { deleteError })
} }
} catch (archiveError) { } catch (archiveError) {
results.archiveFailed++ results.enhancedLogs.archiveFailed++
logger.error(`Failed to archive log ${log.id}:`, { archiveError }) logger.error(`Failed to archive enhanced log ${log.id}:`, { archiveError })
} }
} }
batchesProcessed++ batchesProcessed++
hasMoreLogs = oldLogs.length === BATCH_SIZE hasMoreLogs = oldEnhancedLogs.length === BATCH_SIZE
logger.info(`Processed batch ${batchesProcessed}: ${oldLogs.length} logs`) logger.info(
`Processed enhanced logs batch ${batchesProcessed}: ${oldEnhancedLogs.length} logs`
)
}
// Cleanup orphaned snapshots
try {
const snapshotRetentionDays = Number(env.FREE_PLAN_LOG_RETENTION_DAYS || '7') + 1 // Keep snapshots 1 day longer
const cleanedSnapshots = await snapshotService.cleanupOrphanedSnapshots(snapshotRetentionDays)
results.snapshots.cleaned = cleanedSnapshots
logger.info(`Cleaned up ${cleanedSnapshots} orphaned snapshots`)
} catch (snapshotError) {
results.snapshots.cleanupFailed = 1
logger.error('Error cleaning up orphaned snapshots:', { snapshotError })
} }
const timeElapsed = (Date.now() - startTime) / 1000 const timeElapsed = (Date.now() - startTime) / 1000
const reachedLimit = batchesProcessed >= MAX_BATCHES && hasMoreLogs const reachedLimit = batchesProcessed >= MAX_BATCHES && hasMoreLogs
return NextResponse.json({ return NextResponse.json({
message: `Processed ${batchesProcessed} batches (${results.total} logs) in ${timeElapsed.toFixed(2)}s${reachedLimit ? ' (batch limit reached)' : ''}`, message: `Processed ${batchesProcessed} enhanced log batches (${results.enhancedLogs.total} logs) in ${timeElapsed.toFixed(2)}s${reachedLimit ? ' (batch limit reached)' : ''}`,
results, results,
complete: !hasMoreLogs, complete: !hasMoreLogs,
batchLimitReached: reachedLimit, batchLimitReached: reachedLimit,

View File

@@ -0,0 +1,499 @@
import { and, desc, eq, gte, inArray, lte, or, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { workflow, workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema'
const logger = createLogger('EnhancedLogsAPI')
// Helper function to extract block executions from trace spans
function extractBlockExecutionsFromTraceSpans(traceSpans: any[]): any[] {
const blockExecutions: any[] = []
function processSpan(span: any) {
if (span.blockId) {
blockExecutions.push({
id: span.id,
blockId: span.blockId,
blockName: span.name || '',
blockType: span.type,
startedAt: span.startTime,
endedAt: span.endTime,
durationMs: span.duration || 0,
status: span.status || 'success',
errorMessage: span.output?.error || undefined,
inputData: span.input || {},
outputData: span.output || {},
cost: span.cost || undefined,
metadata: {},
})
}
// Process children recursively
if (span.children && Array.isArray(span.children)) {
span.children.forEach(processSpan)
}
}
traceSpans.forEach(processSpan)
return blockExecutions
}
export const dynamic = 'force-dynamic'
export const revalidate = 0
const QueryParamsSchema = z.object({
includeWorkflow: z.coerce.boolean().optional().default(false),
includeBlocks: z.coerce.boolean().optional().default(false),
limit: z.coerce.number().optional().default(100),
offset: z.coerce.number().optional().default(0),
level: z.string().optional(),
workflowIds: z.string().optional(), // Comma-separated list of workflow IDs
folderIds: z.string().optional(), // Comma-separated list of folder IDs
triggers: z.string().optional(), // Comma-separated list of trigger types
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
})
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized enhanced logs access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
try {
const { searchParams } = new URL(request.url)
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
// Get user's workflows
const userWorkflows = await db
.select({ id: workflow.id, folderId: workflow.folderId })
.from(workflow)
.where(eq(workflow.userId, userId))
const userWorkflowIds = userWorkflows.map((w) => w.id)
if (userWorkflowIds.length === 0) {
return NextResponse.json({ data: [], total: 0 }, { status: 200 })
}
// Build conditions for enhanced logs
let conditions: SQL | undefined = inArray(workflowExecutionLogs.workflowId, userWorkflowIds)
// Filter by level
if (params.level && params.level !== 'all') {
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
}
// Filter by specific workflow IDs
if (params.workflowIds) {
const workflowIds = params.workflowIds.split(',').filter(Boolean)
const filteredWorkflowIds = workflowIds.filter((id) => userWorkflowIds.includes(id))
if (filteredWorkflowIds.length > 0) {
conditions = and(
conditions,
inArray(workflowExecutionLogs.workflowId, filteredWorkflowIds)
)
}
}
// Filter by folder IDs
if (params.folderIds) {
const folderIds = params.folderIds.split(',').filter(Boolean)
const workflowsInFolders = userWorkflows
.filter((w) => w.folderId && folderIds.includes(w.folderId))
.map((w) => w.id)
if (workflowsInFolders.length > 0) {
conditions = and(
conditions,
inArray(workflowExecutionLogs.workflowId, workflowsInFolders)
)
}
}
// Filter by triggers
if (params.triggers) {
const triggers = params.triggers.split(',').filter(Boolean)
if (triggers.length > 0 && !triggers.includes('all')) {
conditions = and(conditions, inArray(workflowExecutionLogs.trigger, triggers))
}
}
// Filter by date range
if (params.startDate) {
conditions = and(
conditions,
gte(workflowExecutionLogs.startedAt, new Date(params.startDate))
)
}
if (params.endDate) {
conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate)))
}
// Filter by search query
if (params.search) {
const searchTerm = `%${params.search}%`
conditions = and(
conditions,
or(
sql`${workflowExecutionLogs.message} ILIKE ${searchTerm}`,
sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`
)
)
}
// Execute the query
const logs = await db
.select()
.from(workflowExecutionLogs)
.where(conditions)
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(params.limit)
.offset(params.offset)
// Get total count for pagination
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(workflowExecutionLogs)
.where(conditions)
const count = countResult[0]?.count || 0
// Get block executions for all workflow executions
const executionIds = logs.map((log) => log.executionId)
let blockExecutionsByExecution: Record<string, any[]> = {}
if (executionIds.length > 0) {
const blockLogs = await db
.select()
.from(workflowExecutionBlocks)
.where(inArray(workflowExecutionBlocks.executionId, executionIds))
.orderBy(workflowExecutionBlocks.startedAt)
// Group block logs by execution ID
blockExecutionsByExecution = blockLogs.reduce(
(acc, blockLog) => {
if (!acc[blockLog.executionId]) {
acc[blockLog.executionId] = []
}
acc[blockLog.executionId].push({
id: blockLog.id,
blockId: blockLog.blockId,
blockName: blockLog.blockName || '',
blockType: blockLog.blockType,
startedAt: blockLog.startedAt.toISOString(),
endedAt: blockLog.endedAt?.toISOString() || blockLog.startedAt.toISOString(),
durationMs: blockLog.durationMs || 0,
status: blockLog.status,
errorMessage: blockLog.errorMessage || undefined,
errorStackTrace: blockLog.errorStackTrace || undefined,
inputData: blockLog.inputData,
outputData: blockLog.outputData,
cost: blockLog.costTotal
? {
input: Number(blockLog.costInput) || 0,
output: Number(blockLog.costOutput) || 0,
total: Number(blockLog.costTotal) || 0,
tokens: {
prompt: blockLog.tokensPrompt || 0,
completion: blockLog.tokensCompletion || 0,
total: blockLog.tokensTotal || 0,
},
model: blockLog.modelUsed || '',
}
: undefined,
metadata: blockLog.metadata || {},
})
return acc
},
{} as Record<string, any[]>
)
}
// Create clean trace spans from block executions
const createTraceSpans = (blockExecutions: any[]) => {
return blockExecutions.map((block, index) => {
// For error blocks, include error information in the output
let output = block.outputData
if (block.status === 'error' && block.errorMessage) {
output = {
...output,
error: block.errorMessage,
stackTrace: block.errorStackTrace,
}
}
return {
id: block.id,
name: `Block ${block.blockName || block.blockType} (${block.blockType})`,
type: block.blockType,
duration: block.durationMs,
startTime: block.startedAt,
endTime: block.endedAt,
status: block.status === 'success' ? 'success' : 'error',
blockId: block.blockId,
input: block.inputData,
output,
tokens: block.cost?.tokens?.total || 0,
relativeStartMs: index * 100,
children: [],
toolCalls: [],
}
})
}
// Extract cost information from block executions
const extractCostSummary = (blockExecutions: any[]) => {
let totalCost = 0
let totalInputCost = 0
let totalOutputCost = 0
let totalTokens = 0
let totalPromptTokens = 0
let totalCompletionTokens = 0
const models = new Map()
blockExecutions.forEach((block) => {
if (block.cost) {
totalCost += Number(block.cost.total) || 0
totalInputCost += Number(block.cost.input) || 0
totalOutputCost += Number(block.cost.output) || 0
totalTokens += block.cost.tokens?.total || 0
totalPromptTokens += block.cost.tokens?.prompt || 0
totalCompletionTokens += block.cost.tokens?.completion || 0
// Track per-model costs
if (block.cost.model) {
if (!models.has(block.cost.model)) {
models.set(block.cost.model, {
input: 0,
output: 0,
total: 0,
tokens: { prompt: 0, completion: 0, total: 0 },
})
}
const modelCost = models.get(block.cost.model)
modelCost.input += Number(block.cost.input) || 0
modelCost.output += Number(block.cost.output) || 0
modelCost.total += Number(block.cost.total) || 0
modelCost.tokens.prompt += block.cost.tokens?.prompt || 0
modelCost.tokens.completion += block.cost.tokens?.completion || 0
modelCost.tokens.total += block.cost.tokens?.total || 0
}
}
})
return {
total: totalCost,
input: totalInputCost,
output: totalOutputCost,
tokens: {
total: totalTokens,
prompt: totalPromptTokens,
completion: totalCompletionTokens,
},
models: Object.fromEntries(models), // Convert Map to object for JSON serialization
}
}
// Transform to clean enhanced log format
const enhancedLogs = logs.map((log) => {
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
// Use stored trace spans from metadata if available, otherwise create from block executions
const storedTraceSpans = (log.metadata as any)?.traceSpans
const traceSpans =
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
? storedTraceSpans
: createTraceSpans(blockExecutions)
// Use extracted cost summary if available, otherwise use stored values
const costSummary =
blockExecutions.length > 0
? extractCostSummary(blockExecutions)
: {
input: Number(log.totalInputCost) || 0,
output: Number(log.totalOutputCost) || 0,
total: Number(log.totalCost) || 0,
tokens: {
total: log.totalTokens || 0,
prompt: (log.metadata as any)?.tokenBreakdown?.prompt || 0,
completion: (log.metadata as any)?.tokenBreakdown?.completion || 0,
},
models: (log.metadata as any)?.models || {},
}
return {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
level: log.level,
message: log.message,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
metadata: {
totalDuration: log.totalDurationMs,
cost: costSummary,
blockStats: {
total: log.blockCount,
success: log.successCount,
error: log.errorCount,
skipped: log.skippedCount,
},
traceSpans,
blockExecutions,
enhanced: true,
},
}
})
if (params.includeWorkflow) {
const workflowIds = [...new Set(logs.map((log) => log.workflowId))]
const workflowConditions = inArray(workflow.id, workflowIds)
const workflowData = await db.select().from(workflow).where(workflowConditions)
const workflowMap = new Map(workflowData.map((w) => [w.id, w]))
const logsWithWorkflow = enhancedLogs.map((log) => ({
...log,
workflow: workflowMap.get(log.workflowId) || null,
}))
return NextResponse.json(
{
data: logsWithWorkflow,
total: Number(count),
page: Math.floor(params.offset / params.limit) + 1,
pageSize: params.limit,
totalPages: Math.ceil(Number(count) / params.limit),
},
{ status: 200 }
)
}
// Include block execution data if requested
if (params.includeBlocks) {
const executionIds = logs.map((log) => log.executionId)
if (executionIds.length > 0) {
const blockLogs = await db
.select()
.from(workflowExecutionBlocks)
.where(inArray(workflowExecutionBlocks.executionId, executionIds))
.orderBy(workflowExecutionBlocks.startedAt)
// Group block logs by execution ID
const blockLogsByExecution = blockLogs.reduce(
(acc, blockLog) => {
if (!acc[blockLog.executionId]) {
acc[blockLog.executionId] = []
}
acc[blockLog.executionId].push({
id: blockLog.id,
blockId: blockLog.blockId,
blockName: blockLog.blockName || '',
blockType: blockLog.blockType,
startedAt: blockLog.startedAt.toISOString(),
endedAt: blockLog.endedAt?.toISOString() || blockLog.startedAt.toISOString(),
durationMs: blockLog.durationMs || 0,
status: blockLog.status,
errorMessage: blockLog.errorMessage || undefined,
inputData: blockLog.inputData,
outputData: blockLog.outputData,
cost: blockLog.costTotal
? {
input: Number(blockLog.costInput) || 0,
output: Number(blockLog.costOutput) || 0,
total: Number(blockLog.costTotal) || 0,
tokens: {
prompt: blockLog.tokensPrompt || 0,
completion: blockLog.tokensCompletion || 0,
total: blockLog.tokensTotal || 0,
},
model: blockLog.modelUsed || '',
}
: undefined,
})
return acc
},
{} as Record<string, any[]>
)
// For executions with no block logs in the database,
// extract block executions from stored trace spans in metadata
logs.forEach((log) => {
if (
!blockLogsByExecution[log.executionId] ||
blockLogsByExecution[log.executionId].length === 0
) {
const storedTraceSpans = (log.metadata as any)?.traceSpans
if (storedTraceSpans && Array.isArray(storedTraceSpans)) {
blockLogsByExecution[log.executionId] =
extractBlockExecutionsFromTraceSpans(storedTraceSpans)
}
}
})
// Add block logs to metadata
const logsWithBlocks = enhancedLogs.map((log) => ({
...log,
metadata: {
...log.metadata,
blockExecutions: blockLogsByExecution[log.executionId] || [],
},
}))
return NextResponse.json(
{
data: logsWithBlocks,
total: Number(count),
page: Math.floor(params.offset / params.limit) + 1,
pageSize: params.limit,
totalPages: Math.ceil(Number(count) / params.limit),
},
{ status: 200 }
)
}
}
// Return basic logs
return NextResponse.json(
{
data: enhancedLogs,
total: Number(count),
page: Math.floor(params.offset / params.limit) + 1,
pageSize: params.limit,
totalPages: Math.ceil(Number(count) / params.limit),
},
{ status: 200 }
)
} catch (validationError) {
if (validationError instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid enhanced logs request parameters`, {
errors: validationError.errors,
})
return NextResponse.json(
{
error: 'Invalid request parameters',
details: validationError.errors,
},
{ status: 400 }
)
}
throw validationError
}
} catch (error: any) {
logger.error(`[${requestId}] Enhanced logs fetch error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}

View File

@@ -1,4 +1,4 @@
import { and, eq, isNull } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db' import { db } from '@/db'
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const memories = await db const memories = await db
.select() .select()
.from(memory) .from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId), isNull(memory.deletedAt))) .where(and(eq(memory.key, id), eq(memory.workflowId, workflowId)))
.orderBy(memory.createdAt) .orderBy(memory.createdAt)
.limit(1) .limit(1)
@@ -112,7 +112,7 @@ export async function DELETE(
const existingMemory = await db const existingMemory = await db
.select({ id: memory.id }) .select({ id: memory.id })
.from(memory) .from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId), isNull(memory.deletedAt))) .where(and(eq(memory.key, id), eq(memory.workflowId, workflowId)))
.limit(1) .limit(1)
if (existingMemory.length === 0) { if (existingMemory.length === 0) {
@@ -128,14 +128,8 @@ export async function DELETE(
) )
} }
// Soft delete by setting deletedAt timestamp // Hard delete the memory
await db await db.delete(memory).where(and(eq(memory.key, id), eq(memory.workflowId, workflowId)))
.update(memory)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId)))
logger.info(`[${requestId}] Memory deleted successfully: ${id} for workflow: ${workflowId}`) logger.info(`[${requestId}] Memory deleted successfully: ${id} for workflow: ${workflowId}`)
return NextResponse.json( return NextResponse.json(
@@ -202,7 +196,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const existingMemories = await db const existingMemories = await db
.select() .select()
.from(memory) .from(memory)
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId), isNull(memory.deletedAt))) .where(and(eq(memory.key, id), eq(memory.workflowId, workflowId)))
.limit(1) .limit(1)
if (existingMemories.length === 0) { if (existingMemories.length === 0) {
@@ -250,13 +244,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
} }
// Update the memory with new data // Update the memory with new data
await db await db.delete(memory).where(and(eq(memory.key, id), eq(memory.workflowId, workflowId)))
.update(memory)
.set({
data,
updatedAt: new Date(),
})
.where(and(eq(memory.key, id), eq(memory.workflowId, workflowId)))
// Fetch the updated memory // Fetch the updated memory
const updatedMemories = await db const updatedMemories = await db

View File

@@ -137,24 +137,22 @@ export async function POST(request: NextRequest) {
const safeExecutionData = { const safeExecutionData = {
success: executionData.success, success: executionData.success,
output: { output: {
response: { // Sanitize content to remove non-ASCII characters that would cause ByteString errors
// Sanitize content to remove non-ASCII characters that would cause ByteString errors content: executionData.output?.content
content: executionData.output?.response?.content ? String(executionData.output.content).replace(/[\u0080-\uFFFF]/g, '')
? String(executionData.output.response.content).replace(/[\u0080-\uFFFF]/g, '') : '',
: '', model: executionData.output?.model,
model: executionData.output?.response?.model, tokens: executionData.output?.tokens || {
tokens: executionData.output?.response?.tokens || { prompt: 0,
prompt: 0, completion: 0,
completion: 0, total: 0,
total: 0,
},
// Sanitize any potential Unicode characters in tool calls
toolCalls: executionData.output?.response?.toolCalls
? sanitizeToolCalls(executionData.output.response.toolCalls)
: undefined,
providerTiming: executionData.output?.response?.providerTiming,
cost: executionData.output?.response?.cost,
}, },
// Sanitize any potential Unicode characters in tool calls
toolCalls: executionData.output?.toolCalls
? sanitizeToolCalls(executionData.output.toolCalls)
: undefined,
providerTiming: executionData.output?.providerTiming,
cost: executionData.output?.cost,
}, },
error: executionData.error, error: executionData.error,
logs: [], // Strip logs from header to avoid encoding issues logs: [], // Strip logs from header to avoid encoding issues

View File

@@ -46,11 +46,19 @@ const formatResponse = (responseData: any, status = 200) => {
*/ */
const createErrorResponse = (error: any, status = 500, additionalData = {}) => { const createErrorResponse = (error: any, status = 500, additionalData = {}) => {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : undefined
logger.error('Creating error response', {
errorMessage,
status,
stack: process.env.NODE_ENV === 'development' ? errorStack : undefined,
})
return formatResponse( return formatResponse(
{ {
success: false, success: false,
error: errorMessage, error: errorMessage,
stack: process.env.NODE_ENV === 'development' ? errorStack : undefined,
...additionalData, ...additionalData,
}, },
status status
@@ -67,6 +75,7 @@ export async function GET(request: Request) {
const requestId = crypto.randomUUID().slice(0, 8) const requestId = crypto.randomUUID().slice(0, 8)
if (!targetUrl) { if (!targetUrl) {
logger.error(`[${requestId}] Missing 'url' parameter`)
return createErrorResponse("Missing 'url' parameter", 400) return createErrorResponse("Missing 'url' parameter", 400)
} }
@@ -126,6 +135,10 @@ export async function GET(request: Request) {
: response.statusText || `HTTP error ${response.status}` : response.statusText || `HTTP error ${response.status}`
: undefined : undefined
if (!response.ok) {
logger.error(`[${requestId}] External API error: ${response.status} ${response.statusText}`)
}
// Return the proxied response // Return the proxied response
return formatResponse({ return formatResponse({
success: response.ok, success: response.ok,
@@ -139,6 +152,7 @@ export async function GET(request: Request) {
logger.error(`[${requestId}] Proxy GET request failed`, { logger.error(`[${requestId}] Proxy GET request failed`, {
url: targetUrl, url: targetUrl,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}) })
return createErrorResponse(error) return createErrorResponse(error)
@@ -151,22 +165,40 @@ export async function POST(request: Request) {
const startTimeISO = startTime.toISOString() const startTimeISO = startTime.toISOString()
try { try {
const { toolId, params } = await request.json() // Parse request body
let requestBody
try {
requestBody = await request.json()
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse request body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
throw new Error('Invalid JSON in request body')
}
logger.debug(`[${requestId}] Proxy request for tool`, { const { toolId, params } = requestBody
toolId,
hasParams: !!params && Object.keys(params).length > 0,
})
if (!toolId) {
logger.error(`[${requestId}] Missing toolId in request`)
throw new Error('Missing toolId in request')
}
logger.info(`[${requestId}] Processing tool: ${toolId}`)
// Get tool
const tool = getTool(toolId) const tool = getTool(toolId)
if (!tool) {
logger.error(`[${requestId}] Tool not found: ${toolId}`)
throw new Error(`Tool not found: ${toolId}`)
}
// Validate the tool and its parameters // Validate the tool and its parameters
try { try {
validateToolRequest(toolId, tool, params) validateToolRequest(toolId, tool, params)
} catch (error) { } catch (validationError) {
logger.warn(`[${requestId}] Tool validation failed`, { logger.warn(`[${requestId}] Tool validation failed for ${toolId}`, {
toolId, error: validationError instanceof Error ? validationError.message : String(validationError),
error: error instanceof Error ? error.message : String(error),
}) })
// Add timing information even to error responses // Add timing information even to error responses
@@ -174,23 +206,18 @@ export async function POST(request: Request) {
const endTimeISO = endTime.toISOString() const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime() const duration = endTime.getTime() - startTime.getTime()
return createErrorResponse(error, 400, { return createErrorResponse(validationError, 400, {
startTime: startTimeISO, startTime: startTimeISO,
endTime: endTimeISO, endTime: endTimeISO,
duration, duration,
}) })
} }
if (!tool) {
logger.error(`[${requestId}] Tool not found`, { toolId })
throw new Error(`Tool not found: ${toolId}`)
}
// Use executeTool with skipProxy=true to prevent recursive proxy calls, and skipPostProcess=true to prevent duplicate post-processing // Execute tool
const result = await executeTool(toolId, params, true, true) const result = await executeTool(toolId, params, true, true)
if (!result.success) { if (!result.success) {
logger.warn(`[${requestId}] Tool execution failed`, { logger.warn(`[${requestId}] Tool execution failed for ${toolId}`, {
toolId,
error: result.error || 'Unknown error', error: result.error || 'Unknown error',
}) })
@@ -217,9 +244,13 @@ export async function POST(request: Request) {
} }
// Fallback // Fallback
throw new Error('Tool returned an error') throw new Error('Tool returned an error')
} catch (e) { } catch (transformError) {
if (e instanceof Error) { logger.error(`[${requestId}] Error transformation failed for ${toolId}`, {
throw e error:
transformError instanceof Error ? transformError.message : String(transformError),
})
if (transformError instanceof Error) {
throw transformError
} }
throw new Error('Tool returned an error') throw new Error('Tool returned an error')
} }
@@ -246,12 +277,7 @@ export async function POST(request: Request) {
}, },
} }
logger.info(`[${requestId}] Tool executed successfully`, { logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`)
toolId,
duration,
startTime: startTimeISO,
endTime: endTimeISO,
})
// Return the response with CORS headers // Return the response with CORS headers
return formatResponse(responseWithTimingData) return formatResponse(responseWithTimingData)
@@ -259,6 +285,7 @@ export async function POST(request: Request) {
logger.error(`[${requestId}] Proxy request failed`, { logger.error(`[${requestId}] Proxy request failed`, {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
}) })
// Add timing information even to error responses // Add timing information even to error responses

View File

@@ -5,7 +5,6 @@
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
createMockRequest,
mockExecutionDependencies, mockExecutionDependencies,
mockScheduleExecuteDb, mockScheduleExecuteDb,
sampleWorkflowState, sampleWorkflowState,
@@ -23,7 +22,7 @@ describe('Scheduled Workflow Execution API Route', () => {
blocks: sampleWorkflowState.blocks, blocks: sampleWorkflowState.blocks,
edges: sampleWorkflowState.edges || [], edges: sampleWorkflowState.edges || [],
loops: sampleWorkflowState.loops || {}, loops: sampleWorkflowState.loops || {},
parallels: sampleWorkflowState.parallels || {}, parallels: {},
isFromNormalizedTables: true, isFromNormalizedTables: true,
}), }),
})) }))
@@ -122,9 +121,8 @@ describe('Scheduled Workflow Execution API Route', () => {
})), })),
})) }))
const req = createMockRequest('GET')
const { GET } = await import('./route') const { GET } = await import('./route')
const response = await GET(req) const response = await GET()
expect(response).toBeDefined() expect(response).toBeDefined()
const data = await response.json() const data = await response.json()
@@ -136,7 +134,6 @@ describe('Scheduled Workflow Execution API Route', () => {
const persistExecutionErrorMock = vi.fn().mockResolvedValue(undefined) const persistExecutionErrorMock = vi.fn().mockResolvedValue(undefined)
vi.doMock('@/lib/logs/execution-logger', () => ({ vi.doMock('@/lib/logs/execution-logger', () => ({
persistExecutionLogs: vi.fn().mockResolvedValue(undefined),
persistExecutionError: persistExecutionErrorMock, persistExecutionError: persistExecutionErrorMock,
})) }))
@@ -146,9 +143,8 @@ describe('Scheduled Workflow Execution API Route', () => {
})), })),
})) }))
const req = createMockRequest('GET')
const { GET } = await import('./route') const { GET } = await import('./route')
const response = await GET(req) const response = await GET()
expect(response).toBeDefined() expect(response).toBeDefined()
@@ -176,9 +172,8 @@ describe('Scheduled Workflow Execution API Route', () => {
return { db: mockDb } return { db: mockDb }
}) })
const req = createMockRequest('GET')
const { GET } = await import('./route') const { GET } = await import('./route')
const response = await GET(req) const response = await GET()
expect(response.status).toBe(200) expect(response.status).toBe(200)
const data = await response.json() const data = await response.json()
expect(data).toHaveProperty('executedCount', 0) expect(data).toHaveProperty('executedCount', 0)
@@ -205,9 +200,8 @@ describe('Scheduled Workflow Execution API Route', () => {
return { db: mockDb } return { db: mockDb }
}) })
const req = createMockRequest('GET')
const { GET } = await import('./route') const { GET } = await import('./route')
const response = await GET(req) const response = await GET()
expect(response.status).toBe(500) expect(response.status).toBe(500)
const data = await response.json() const data = await response.json()
@@ -238,9 +232,8 @@ describe('Scheduled Workflow Execution API Route', () => {
], ],
}) })
const req = createMockRequest('GET')
const { GET } = await import('./route') const { GET } = await import('./route')
const response = await GET(req) const response = await GET()
expect(response.status).toBe(200) expect(response.status).toBe(200)
}) })
@@ -269,9 +262,8 @@ describe('Scheduled Workflow Execution API Route', () => {
], ],
}) })
const req = createMockRequest('GET')
const { GET } = await import('./route') const { GET } = await import('./route')
const response = await GET(req) const response = await GET()
expect(response.status).toBe(200) expect(response.status).toBe(200)
const data = await response.json() const data = await response.json()

View File

@@ -1,10 +1,10 @@
import { Cron } from 'croner' import { Cron } from 'croner'
import { and, eq, lte, not, sql } from 'drizzle-orm' import { and, eq, lte, not, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod' import { z } from 'zod'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { persistExecutionError, persistExecutionLogs } from '@/lib/logs/execution-logger' import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans' import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { import {
type BlockState, type BlockState,
@@ -17,7 +17,7 @@ import { decryptSecret } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { db } from '@/db' import { db } from '@/db'
import { environment, userStats, workflow, workflowSchedule } from '@/db/schema' import { environment as environmentTable, userStats, workflow, workflowSchedule } from '@/db/schema'
import { Executor } from '@/executor' import { Executor } from '@/executor'
import { Serializer } from '@/serializer' import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils' import { mergeSubblockState } from '@/stores/workflows/server-utils'
@@ -58,7 +58,7 @@ const EnvVarsSchema = z.record(z.string())
const runningExecutions = new Set<string>() const runningExecutions = new Set<string>()
export async function GET(req: NextRequest) { export async function GET() {
logger.info(`Scheduled execution triggered at ${new Date().toISOString()}`) logger.info(`Scheduled execution triggered at ${new Date().toISOString()}`)
const requestId = crypto.randomUUID().slice(0, 8) const requestId = crypto.randomUUID().slice(0, 8)
const now = new Date() const now = new Date()
@@ -85,6 +85,7 @@ export async function GET(req: NextRequest) {
for (const schedule of dueSchedules) { for (const schedule of dueSchedules) {
const executionId = uuidv4() const executionId = uuidv4()
let loggingSession: EnhancedLoggingSession | null = null
try { try {
if (runningExecutions.has(schedule.workflowId)) { if (runningExecutions.has(schedule.workflowId)) {
@@ -118,15 +119,7 @@ export async function GET(req: NextRequest) {
} }
) )
await persistExecutionError( // Error logging handled by enhanced logging session
schedule.workflowId,
executionId,
new Error(
usageCheck.message ||
'Usage limit exceeded. Please upgrade your plan to continue running scheduled workflows.'
),
'schedule'
)
const retryDelay = 24 * 60 * 60 * 1000 // 24 hour delay for exceeded limits const retryDelay = 24 * 60 * 60 * 1000 // 24 hour delay for exceeded limits
const nextRetryAt = new Date(now.getTime() + retryDelay) const nextRetryAt = new Date(now.getTime() + retryDelay)
@@ -176,8 +169,8 @@ export async function GET(req: NextRequest) {
// Retrieve environment variables for this user (if any). // Retrieve environment variables for this user (if any).
const [userEnv] = await db const [userEnv] = await db
.select() .select()
.from(environment) .from(environmentTable)
.where(eq(environment.userId, workflowRecord.userId)) .where(eq(environmentTable.userId, workflowRecord.userId))
.limit(1) .limit(1)
if (!userEnv) { if (!userEnv) {
@@ -306,6 +299,30 @@ export async function GET(req: NextRequest) {
logger.debug(`[${requestId}] No workflow variables found for: ${schedule.workflowId}`) logger.debug(`[${requestId}] No workflow variables found for: ${schedule.workflowId}`)
} }
// Start enhanced logging
loggingSession = new EnhancedLoggingSession(
schedule.workflowId,
executionId,
'schedule',
requestId
)
// Load the actual workflow state from normalized tables
const enhancedNormalizedData = await loadWorkflowFromNormalizedTables(schedule.workflowId)
if (!enhancedNormalizedData) {
throw new Error(
`Workflow ${schedule.workflowId} has no normalized data available. Ensure the workflow is properly saved to normalized tables.`
)
}
// Start enhanced logging with environment variables
await loggingSession.safeStart({
userId: workflowRecord.userId,
workspaceId: workflowRecord.workspaceId || '',
variables: variables || {},
})
const executor = new Executor( const executor = new Executor(
serializedWorkflow, serializedWorkflow,
processedBlockStates, processedBlockStates,
@@ -313,6 +330,10 @@ export async function GET(req: NextRequest) {
input, input,
workflowVariables workflowVariables
) )
// Set up enhanced logging on the executor
loggingSession.setupExecutor(executor)
const result = await executor.execute(schedule.workflowId) const result = await executor.execute(schedule.workflowId)
const executionResult = const executionResult =
@@ -343,13 +364,16 @@ export async function GET(req: NextRequest) {
const { traceSpans, totalDuration } = buildTraceSpans(executionResult) const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
const enrichedResult = { // Log individual block executions to enhanced system are automatically
...executionResult, // handled by the logging session
traceSpans,
totalDuration,
}
await persistExecutionLogs(schedule.workflowId, executionId, enrichedResult, 'schedule') // Complete enhanced logging
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
finalOutput: executionResult.output || {},
traceSpans: (traceSpans || []) as any,
})
if (executionResult.success) { if (executionResult.success) {
logger.info(`[${requestId}] Workflow ${schedule.workflowId} executed successfully`) logger.info(`[${requestId}] Workflow ${schedule.workflowId} executed successfully`)
@@ -413,7 +437,18 @@ export async function GET(req: NextRequest) {
error error
) )
await persistExecutionError(schedule.workflowId, executionId, error, 'schedule') // Error logging handled by enhanced logging session
if (loggingSession) {
await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Scheduled workflow execution failed',
stackTrace: error.stack,
},
})
}
let nextRunAt: Date let nextRunAt: Date
try { try {

View File

@@ -0,0 +1,480 @@
import {
ApiGatewayV2Client,
CreateApiCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateStageCommand,
GetApisCommand,
GetIntegrationsCommand,
GetRoutesCommand,
GetStagesCommand,
} from '@aws-sdk/client-apigatewayv2'
import { AddPermissionCommand, GetFunctionCommand, LambdaClient } from '@aws-sdk/client-lambda'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('AWSLambdaDeployEndpointAPI')
// Validation schema for the request body
const DeployEndpointRequestSchema = z.object({
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
region: z.string().min(1, 'AWS Region is required'),
functionName: z.string().min(1, 'Function name is required'),
endpointName: z.string().min(1, 'Endpoint name is required'),
role: z.string().min(1, 'Role ARN is required'),
})
type DeployEndpointRequest = z.infer<typeof DeployEndpointRequestSchema>
interface DeployEndpointResponse {
functionArn: string
functionName: string
endpointName: string
endpointUrl: string
region: string
status: string
lastModified: string
apiGatewayId: string
stageName: string
}
/**
* Check if a Lambda function exists
*/
async function checkFunctionExists(
lambdaClient: LambdaClient,
functionName: string
): Promise<boolean> {
try {
await lambdaClient.send(new GetFunctionCommand({ FunctionName: functionName }))
return true
} catch (error: any) {
if (error.name === 'ResourceNotFoundException') {
return false
}
throw error
}
}
/**
* Get Lambda function details
*/
async function getFunctionDetails(lambdaClient: LambdaClient, functionName: string): Promise<any> {
return await lambdaClient.send(new GetFunctionCommand({ FunctionName: functionName }))
}
/**
* Check if API Gateway HTTP API already exists
*/
async function checkApiExists(
apiGatewayClient: ApiGatewayV2Client,
apiName: string
): Promise<string | null> {
try {
const apis = await apiGatewayClient.send(new GetApisCommand({}))
const existingApi = apis.Items?.find((api: any) => api.Name === apiName)
return existingApi?.ApiId || null
} catch (error) {
logger.error('Error checking for existing API', { error })
return null
}
}
/**
* Check if a route already exists for the API Gateway
*/
async function checkRouteExists(
apiGatewayClient: ApiGatewayV2Client,
apiId: string,
routeKey: string
): Promise<boolean> {
try {
const routes = await apiGatewayClient.send(new GetRoutesCommand({ ApiId: apiId }))
return routes.Items?.some((route: any) => route.RouteKey === routeKey) || false
} catch (error) {
logger.error('Error checking for existing route', { error })
return false
}
}
/**
* Check if an integration already exists for the API Gateway
*/
async function checkIntegrationExists(
apiGatewayClient: ApiGatewayV2Client,
apiId: string,
functionArn: string
): Promise<string | null> {
try {
const integrations = await apiGatewayClient.send(new GetIntegrationsCommand({ ApiId: apiId }))
const existingIntegration = integrations.Items?.find(
(integration) => integration.IntegrationUri === functionArn
)
return existingIntegration?.IntegrationId || null
} catch (error) {
logger.error('Error checking for existing integration', { error })
return null
}
}
/**
* Create a new API Gateway HTTP API
*/
async function createApiGateway(
apiGatewayClient: ApiGatewayV2Client,
apiName: string
): Promise<string> {
const createApiResponse = await apiGatewayClient.send(
new CreateApiCommand({
Name: apiName,
ProtocolType: 'HTTP',
Description: `HTTP API for Lambda function ${apiName}`,
})
)
if (!createApiResponse.ApiId) {
throw new Error('Failed to create API Gateway - no ID returned')
}
return createApiResponse.ApiId
}
/**
* Create API Gateway integration with Lambda
*/
async function createApiIntegration(
apiGatewayClient: ApiGatewayV2Client,
apiId: string,
functionArn: string
): Promise<string> {
const integration = await apiGatewayClient.send(
new CreateIntegrationCommand({
ApiId: apiId,
IntegrationType: 'AWS_PROXY',
IntegrationUri: functionArn,
IntegrationMethod: 'POST',
PayloadFormatVersion: '2.0',
})
)
if (!integration.IntegrationId) {
throw new Error('Failed to create integration - no ID returned')
}
return integration.IntegrationId
}
/**
* Create a route for the API Gateway
*/
async function createApiRoute(
apiGatewayClient: ApiGatewayV2Client,
apiId: string,
integrationId: string
): Promise<void> {
await apiGatewayClient.send(
new CreateRouteCommand({
ApiId: apiId,
RouteKey: 'ANY /',
Target: `integrations/${integrationId}`,
})
)
}
/**
* Add Lambda permission for API Gateway
*/
async function addLambdaPermission(
lambdaClient: LambdaClient,
functionName: string,
apiId: string,
region: string,
accountId: string
): Promise<void> {
try {
await lambdaClient.send(
new AddPermissionCommand({
FunctionName: functionName,
StatementId: `api-gateway-${apiId}`,
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: `arn:aws:execute-api:${region}:${accountId}:${apiId}/*/*`,
})
)
} catch (error: any) {
// If permission already exists, that's fine
if (error.name !== 'ResourceConflictException') {
throw error
}
}
}
/**
* Check if a stage exists for the API Gateway
*/
async function checkStageExists(
apiGatewayClient: ApiGatewayV2Client,
apiId: string,
stageName: string
): Promise<boolean> {
try {
const stages = await apiGatewayClient.send(
new GetStagesCommand({
ApiId: apiId,
})
)
return stages.Items?.some((stage: any) => stage.StageName === stageName) || false
} catch (error) {
logger.error('Error checking for existing stage', { error })
return false
}
}
/**
* Create a stage for the API Gateway
*/
async function createApiStage(
apiGatewayClient: ApiGatewayV2Client,
apiId: string
): Promise<string> {
const stageName = 'prod'
// Check if stage already exists
const stageExists = await checkStageExists(apiGatewayClient, apiId, stageName)
if (stageExists) {
logger.info(`Stage ${stageName} already exists for API ${apiId}`)
return stageName
}
logger.info(`Creating new stage ${stageName} for API ${apiId}`)
const stage = await apiGatewayClient.send(
new CreateStageCommand({
ApiId: apiId,
StageName: stageName,
AutoDeploy: true,
})
)
return stage.StageName || stageName
}
/**
* Ensure API is deployed by waiting for deployment to complete
*/
async function ensureApiDeployed(
apiGatewayClient: ApiGatewayV2Client,
apiId: string,
stageName: string
): Promise<void> {
// In API Gateway v2, AutoDeploy: true should handle deployment automatically
// But we can add a small delay to ensure the deployment completes
await new Promise((resolve) => setTimeout(resolve, 2000))
logger.info(`API Gateway deployment completed for API ${apiId}, stage ${stageName}`)
}
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.info(`[${requestId}] Processing AWS Lambda deploy endpoint request`)
// Parse and validate request body
let body: any
try {
body = await request.json()
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse request body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
return createErrorResponse('Invalid JSON in request body', 400, 'INVALID_JSON')
}
// Log the raw request body for debugging
logger.info(`[${requestId}] Raw request body received`, {
body: JSON.stringify(body, null, 2),
})
const validationResult = DeployEndpointRequestSchema.safeParse(body)
if (!validationResult.success) {
logger.warn(`[${requestId}] Invalid request body`, { errors: validationResult.error.errors })
return createErrorResponse('Invalid request parameters', 400, 'VALIDATION_ERROR')
}
const params = validationResult.data
// Log the deployment payload (excluding sensitive credentials)
logger.info(`[${requestId}] AWS Lambda deploy endpoint payload received`, {
functionName: params.functionName,
endpointName: params.endpointName,
region: params.region,
accessKeyId: params.accessKeyId ? `${params.accessKeyId.substring(0, 4)}...` : undefined,
hasSecretAccessKey: !!params.secretAccessKey,
hasRole: !!params.role,
role: params.role ? `${params.role.substring(0, 20)}...` : undefined,
})
logger.info(`[${requestId}] Deploying Lambda function as endpoint: ${params.functionName}`)
// Create Lambda client
const lambdaClient = new LambdaClient({
region: params.region,
credentials: {
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
},
})
// Create API Gateway v2 client
const apiGatewayClient = new ApiGatewayV2Client({
region: params.region,
credentials: {
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
},
})
// Check if Lambda function exists
const functionExists = await checkFunctionExists(lambdaClient, params.functionName)
if (!functionExists) {
logger.error(`[${requestId}] Lambda function ${params.functionName} does not exist`)
return createErrorResponse(
`Lambda function ${params.functionName} does not exist. Please deploy the function first.`,
404,
'FUNCTION_NOT_FOUND'
)
}
// Get function details
const functionDetails = await getFunctionDetails(lambdaClient, params.functionName)
const functionArn = functionDetails.Configuration?.FunctionArn
if (!functionArn) {
logger.error(`[${requestId}] Failed to get function ARN for ${params.functionName}`)
return createErrorResponse('Failed to get function ARN', 500, 'FUNCTION_ARN_ERROR')
}
// Extract account ID from function ARN
const accountId = functionArn.split(':')[4]
if (!accountId) {
logger.error(`[${requestId}] Failed to extract account ID from function ARN: ${functionArn}`)
return createErrorResponse(
'Failed to extract account ID from function ARN',
500,
'ACCOUNT_ID_ERROR'
)
}
// Check if API Gateway already exists
let apiId = await checkApiExists(apiGatewayClient, params.endpointName)
if (!apiId) {
logger.info(`[${requestId}] Creating new API Gateway HTTP API: ${params.endpointName}`)
apiId = await createApiGateway(apiGatewayClient, params.endpointName)
} else {
logger.info(
`[${requestId}] Using existing API Gateway HTTP API: ${params.endpointName} (${apiId})`
)
}
// Check if integration already exists before creating a new one
let integrationId = await checkIntegrationExists(apiGatewayClient, apiId, functionArn)
if (integrationId) {
logger.info(
`[${requestId}] Integration for function ${params.functionName} already exists for API ${apiId}, using existing integration`
)
} else {
logger.info(`[${requestId}] Creating API Gateway integration`)
integrationId = await createApiIntegration(apiGatewayClient, apiId, functionArn)
}
// Check if route already exists before creating a new one
const routeKey = 'ANY /'
const routeExists = await checkRouteExists(apiGatewayClient, apiId, routeKey)
if (routeExists) {
logger.info(
`[${requestId}] Route ${routeKey} already exists for API ${apiId}, skipping route creation`
)
} else {
logger.info(`[${requestId}] Creating API Gateway route`)
await createApiRoute(apiGatewayClient, apiId, integrationId)
}
// Add Lambda permission for API Gateway
logger.info(`[${requestId}] Adding Lambda permission for API Gateway`)
await addLambdaPermission(lambdaClient, params.functionName, apiId, params.region, accountId)
// Create stage for the API Gateway
logger.info(`[${requestId}] Creating API Gateway stage`)
const stageName = await createApiStage(apiGatewayClient, apiId)
if (!stageName) {
logger.error(`[${requestId}] Failed to create or get stage for API ${apiId}`)
return createErrorResponse('Failed to create API Gateway stage', 500, 'STAGE_CREATION_ERROR')
}
// Ensure API is deployed
logger.info(`[${requestId}] Ensuring API Gateway deployment is complete`)
await ensureApiDeployed(apiGatewayClient, apiId, stageName)
// Construct the endpoint URL
const endpointUrl = `https://${apiId}.execute-api.${params.region}.amazonaws.com/${stageName}/`
const response: DeployEndpointResponse = {
functionArn,
functionName: params.functionName,
endpointName: params.endpointName,
endpointUrl,
region: params.region,
status: 'ACTIVE',
lastModified: new Date().toISOString(),
apiGatewayId: apiId,
stageName,
}
logger.info(`[${requestId}] Lambda function endpoint deployment completed successfully`, {
functionName: params.functionName,
endpointName: params.endpointName,
endpointUrl,
apiGatewayId: apiId,
})
return createSuccessResponse({
success: true,
output: response,
})
} catch (error: any) {
logger.error(`[${requestId}] Error deploying Lambda function endpoint`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
// Handle specific AWS errors
let errorMessage = 'Failed to deploy Lambda function endpoint'
let statusCode = 500
if (error.name === 'AccessDeniedException') {
errorMessage = 'Access denied. Please check your AWS credentials and permissions.'
statusCode = 403
} else if (error.name === 'InvalidParameterValueException') {
errorMessage = `Invalid parameter: ${error.message}`
statusCode = 400
} else if (error.name === 'ResourceConflictException') {
errorMessage = 'Resource conflict. The API may be in use or being updated.'
statusCode = 409
} else if (error.name === 'ServiceException') {
errorMessage = 'AWS service error. Please try again later.'
statusCode = 503
} else if (error instanceof Error) {
errorMessage = error.message
}
return createErrorResponse(errorMessage, statusCode, 'DEPLOYMENT_ERROR')
}
}

View File

@@ -0,0 +1,442 @@
import { promises as fs } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { GetFunctionCommand, LambdaClient } from '@aws-sdk/client-lambda'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('AWSLambdaDeployAPI')
// Validation schema for the request body
const DeployRequestSchema = z.object({
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
region: z.string().min(1, 'AWS Region is required'),
functionName: z.string().min(1, 'Function name is required'),
handler: z.string().optional(),
runtime: z.string().min(1, 'Runtime is required'),
code: z
.record(z.string())
.refine((val) => Object.keys(val).length > 0, 'At least one code file is required'),
timeout: z.coerce.number().min(1).max(900).optional().default(3),
memorySize: z.coerce.number().min(128).max(10240).optional().default(128),
environmentVariables: z.record(z.string()).default({}),
tags: z.record(z.string()).default({}),
role: z.string().min(1, 'Role ARN is required'),
})
type DeployRequest = z.infer<typeof DeployRequestSchema>
interface LambdaFunctionDetails {
functionArn: string
functionName: string
runtime: string
region: string
status: string
lastModified: string
codeSize: number
description: string
timeout: number
memorySize: number
environment: Record<string, string>
tags: Record<string, string>
}
/**
* Get the appropriate file extension for the given runtime
*/
function getFileExtension(runtime: string): string {
if (runtime.startsWith('nodejs')) return 'js'
if (runtime.startsWith('python')) return 'py'
if (runtime.startsWith('java')) return 'java'
if (runtime.startsWith('dotnet')) return 'cs'
if (runtime.startsWith('go')) return 'go'
if (runtime.startsWith('ruby')) return 'rb'
return 'js' // default
}
/**
* Sanitize function name for SAM/CloudFormation resource naming
* SAM resource names must be alphanumeric only (letters and numbers)
*/
function sanitizeResourceName(functionName: string): string {
return (
functionName
.replace(/[^a-zA-Z0-9]/g, '') // Remove all non-alphanumeric characters
.replace(/^(\d)/, 'Func$1') // Ensure it starts with a letter if it starts with a number
.substring(0, 64) || // Ensure reasonable length limit
'LambdaFunction'
) // Fallback if name becomes empty
}
/**
* Create SAM template for the Lambda function
*/
function createSamTemplate(params: DeployRequest): string {
// Sanitize the function name for CloudFormation resource naming
const resourceName = sanitizeResourceName(params.functionName)
const template = {
AWSTemplateFormatVersion: '2010-09-09',
Transform: 'AWS::Serverless-2016-10-31',
Resources: {
[resourceName]: {
Type: 'AWS::Serverless::Function',
Properties: {
FunctionName: params.functionName, // Use original function name for actual Lambda function
CodeUri: './src',
Handler: params.handler,
Runtime: params.runtime,
Role: params.role,
Timeout: params.timeout,
MemorySize: params.memorySize,
Environment: {
Variables: params.environmentVariables,
},
Tags: params.tags,
},
},
},
Outputs: {
FunctionArn: {
Value: { 'Fn::GetAtt': [resourceName, 'Arn'] },
Export: { Name: `${params.functionName}-Arn` },
},
},
}
return JSON.stringify(template, null, 2)
}
/**
* Execute a shell command and return the result
*/
async function execCommand(
command: string,
cwd: string,
env?: Record<string, string>
): Promise<{ stdout: string; stderr: string }> {
const { exec } = await import('child_process')
const { promisify } = await import('util')
const execAsync = promisify(exec)
return await execAsync(command, {
cwd,
env: env ? { ...process.env, ...env } : process.env,
})
}
/**
* Deploy Lambda function using SAM CLI
*/
async function deployWithSam(
params: DeployRequest,
requestId: string
): Promise<LambdaFunctionDetails> {
const tempDir = join(tmpdir(), `lambda-deploy-${requestId}`)
const srcDir = join(tempDir, 'src')
try {
// Create temporary directory structure
await fs.mkdir(tempDir, { recursive: true })
await fs.mkdir(srcDir, { recursive: true })
logger.info(`[${requestId}] Created temporary directory: ${tempDir}`)
// Write SAM template
const samTemplate = createSamTemplate(params)
await fs.writeFile(join(tempDir, 'template.yaml'), samTemplate)
logger.info(`[${requestId}] Created SAM template`)
// Write source code files
for (const [filePath, codeContent] of Object.entries(params.code)) {
const fullPath = join(srcDir, filePath)
const fileDir = join(fullPath, '..')
// Ensure directory exists
await fs.mkdir(fileDir, { recursive: true })
await fs.writeFile(fullPath, codeContent)
logger.info(`[${requestId}] Created source file: ${filePath}`)
}
// Set AWS credentials in environment
const env = {
AWS_ACCESS_KEY_ID: params.accessKeyId,
AWS_SECRET_ACCESS_KEY: params.secretAccessKey,
AWS_DEFAULT_REGION: params.region,
}
// Build the SAM application
logger.info(`[${requestId}] Building SAM application...`)
const buildCommand = 'sam build --no-cached'
const buildResult = await execCommand(buildCommand, tempDir, env)
logger.info(`[${requestId}] SAM build output:`, {
stdout: buildResult.stdout,
stderr: buildResult.stderr,
})
if (buildResult.stderr && !buildResult.stderr.includes('Successfully built')) {
logger.warn(`[${requestId}] SAM build warnings:`, { stderr: buildResult.stderr })
}
logger.info(`[${requestId}] SAM build completed`)
// Deploy the SAM application
logger.info(`[${requestId}] Deploying SAM application...`)
const stackName = `${sanitizeResourceName(params.functionName)}Stack`
const deployCommand = [
'sam deploy',
'--no-confirm-changeset',
'--no-fail-on-empty-changeset',
`--stack-name ${stackName}`,
`--region ${params.region}`,
'--resolve-s3',
'--capabilities CAPABILITY_IAM',
'--no-progressbar',
].join(' ')
const deployResult = await execCommand(deployCommand, tempDir, env)
logger.info(`[${requestId}] SAM deploy output:`, {
stdout: deployResult.stdout,
stderr: deployResult.stderr,
})
if (
deployResult.stderr &&
!deployResult.stderr.includes('Successfully created/updated stack')
) {
logger.warn(`[${requestId}] SAM deploy warnings:`, { stderr: deployResult.stderr })
}
logger.info(`[${requestId}] SAM deploy completed`)
// Get function details using AWS SDK
const lambdaClient = new LambdaClient({
region: params.region,
credentials: {
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
},
})
const functionDetails = await getFunctionDetails(
lambdaClient,
params.functionName,
params.region
)
return functionDetails
} catch (error) {
logger.error(`[${requestId}] Error during SAM deployment`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
throw error
} finally {
// Clean up temporary directory
try {
await fs.rm(tempDir, { recursive: true, force: true })
logger.info(`[${requestId}] Cleaned up temporary directory: ${tempDir}`)
} catch (cleanupError) {
logger.warn(`[${requestId}] Failed to clean up temporary directory`, {
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
}
}
/**
* Get detailed information about a Lambda function
*/
async function getFunctionDetails(
lambdaClient: LambdaClient,
functionName: string,
region: string
): Promise<LambdaFunctionDetails> {
const functionDetails = await lambdaClient.send(
new GetFunctionCommand({ FunctionName: functionName })
)
return {
functionArn: functionDetails.Configuration?.FunctionArn || '',
functionName: functionDetails.Configuration?.FunctionName || '',
runtime: functionDetails.Configuration?.Runtime || '',
region,
status: functionDetails.Configuration?.State || '',
lastModified: functionDetails.Configuration?.LastModified || '',
codeSize: functionDetails.Configuration?.CodeSize || 0,
description: functionDetails.Configuration?.Description || '',
timeout: functionDetails.Configuration?.Timeout || 0,
memorySize: functionDetails.Configuration?.MemorySize || 0,
environment: functionDetails.Configuration?.Environment?.Variables || {},
tags: functionDetails.Tags || {},
}
}
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.info(`[${requestId}] Processing AWS Lambda deployment request`)
// Parse and validate request body
let body: any
try {
body = await request.json()
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse request body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
return createErrorResponse('Invalid JSON in request body', 400, 'INVALID_JSON')
}
logger.info(`[${requestId}] Request body received:`, {
body,
codeType: typeof body.code,
codeValue: body.code,
})
// Parse the code field if it's a JSON string
if (typeof body.code === 'string') {
try {
body.code = JSON.parse(body.code)
logger.info(`[${requestId}] Parsed code field:`, { parsedCode: body.code })
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse code field as JSON`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
codeString: body.code,
})
return createErrorResponse('Invalid JSON in code field', 400, 'INVALID_CODE_JSON')
}
}
// Runtime field should be a string, no JSON parsing needed
if (typeof body.runtime !== 'string') {
logger.error(`[${requestId}] Runtime field must be a string`, {
runtimeType: typeof body.runtime,
runtimeValue: body.runtime,
})
return createErrorResponse('Runtime field must be a string', 400, 'INVALID_RUNTIME_TYPE')
}
// Parse the timeout field if it's a JSON string
if (typeof body.timeout === 'string') {
try {
body.timeout = JSON.parse(body.timeout)
logger.info(`[${requestId}] Parsed timeout field:`, { parsedTimeout: body.timeout })
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse timeout field as JSON`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
timeoutString: body.timeout,
})
return createErrorResponse('Invalid JSON in timeout field', 400, 'INVALID_TIMEOUT_JSON')
}
}
// Parse the memorySize field if it's a JSON string
if (typeof body.memorySize === 'string') {
try {
body.memorySize = JSON.parse(body.memorySize)
logger.info(`[${requestId}] Parsed memorySize field:`, {
parsedMemorySize: body.memorySize,
})
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse memorySize field as JSON`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
memorySizeString: body.memorySize,
})
return createErrorResponse(
'Invalid JSON in memorySize field',
400,
'INVALID_MEMORYSIZE_JSON'
)
}
}
const validationResult = DeployRequestSchema.safeParse(body)
if (!validationResult.success) {
logger.warn(`[${requestId}] Invalid request body`, {
errors: validationResult.error.errors,
codeField: body.code,
codeType: typeof body.code,
hasCode: 'code' in body,
bodyKeys: Object.keys(body),
})
return createErrorResponse('Invalid request parameters', 400, 'VALIDATION_ERROR')
}
const params = validationResult.data
// Log the deployment payload (excluding sensitive credentials)
logger.info(`[${requestId}] AWS Lambda deployment payload received`, {
functionName: params.functionName,
region: params.region,
runtime: params.runtime,
handler: params.handler,
timeout: params.timeout,
memorySize: params.memorySize,
accessKeyId: params.accessKeyId ? `${params.accessKeyId.substring(0, 4)}...` : undefined,
hasSecretAccessKey: !!params.secretAccessKey,
hasRole: !!params.role,
role: params.role ? `${params.role.substring(0, 20)}...` : undefined,
codeFiles: Object.keys(params.code),
codeFilesCount: Object.keys(params.code).length,
environmentVariables: params.environmentVariables,
environmentVariablesCount: Object.keys(params.environmentVariables || {}).length,
tags: params.tags,
tagsCount: Object.keys(params.tags || {}).length,
})
logger.info(`[${requestId}] Deploying Lambda function with SAM: ${params.functionName}`)
// Deploy using SAM CLI
const functionDetails = await deployWithSam(params, requestId)
logger.info(`[${requestId}] Lambda function deployment completed successfully`, {
functionName: params.functionName,
functionArn: functionDetails.functionArn,
})
return createSuccessResponse({
success: true,
output: functionDetails,
})
} catch (error: any) {
logger.error(`[${requestId}] Error deploying Lambda function`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
// Handle specific AWS errors
let errorMessage = 'Failed to deploy Lambda function'
let statusCode = 500
if (error.message?.includes('sam: command not found')) {
errorMessage = 'SAM CLI is not installed or not available in PATH'
statusCode = 500
} else if (error.name === 'AccessDeniedException') {
errorMessage = 'Access denied. Please check your AWS credentials and permissions.'
statusCode = 403
} else if (error.name === 'InvalidParameterValueException') {
errorMessage = `Invalid parameter: ${error.message}`
statusCode = 400
} else if (error.name === 'ResourceConflictException') {
errorMessage = 'Resource conflict. The function may be in use or being updated.'
statusCode = 409
} else if (error.name === 'ServiceException') {
errorMessage = 'AWS Lambda service error. Please try again later.'
statusCode = 503
} else if (error instanceof Error) {
errorMessage = error.message
}
return createErrorResponse(errorMessage, statusCode, 'DEPLOYMENT_ERROR')
}
}

View File

@@ -0,0 +1,322 @@
import {
GetFunctionCommand,
GetFunctionConfigurationCommand,
LambdaClient,
} from '@aws-sdk/client-lambda'
import JSZip from 'jszip'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('AWSLambdaFetchAPI')
// Validation schema for the request body
const FetchRequestSchema = z.object({
accessKeyId: z.string().min(1, 'AWS Access Key ID is required'),
secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'),
region: z.string().min(1, 'AWS Region is required'),
functionName: z.string().min(1, 'Function name is required'),
role: z.string().min(1, 'IAM Role ARN is required'),
})
type FetchRequest = z.infer<typeof FetchRequestSchema>
interface LambdaFunctionDetails {
functionArn: string
functionName: string
runtime: string
region: string
status: string
lastModified: string
codeSize: number
description: string
timeout: number
memorySize: number
environment: Record<string, string>
tags: Record<string, string>
codeFiles: Record<string, string>
handler: string
role: string
}
/**
* Extract code from Lambda function ZIP file
*/
async function extractCodeFromZip(
zipBuffer: Buffer,
runtime: string
): Promise<{ mainCode: string; allFiles: Record<string, string> }> {
try {
const zip = await JSZip.loadAsync(zipBuffer)
const allFiles = Object.keys(zip.files)
logger.info('Files in ZIP:', allFiles)
// Extract all text files
const allFilesContent: Record<string, string> = {}
let mainCode = ''
// Determine the main file based on runtime
let mainFile = 'index.js' // default
if (runtime.startsWith('python')) {
mainFile = 'index.py'
} else if (runtime.startsWith('java')) {
mainFile = 'index.java'
} else if (runtime.startsWith('dotnet')) {
mainFile = 'index.cs'
} else if (runtime.startsWith('go')) {
mainFile = 'index.go'
} else if (runtime.startsWith('ruby')) {
mainFile = 'index.rb'
}
logger.info('Looking for main file:', mainFile)
// Extract all non-directory files
for (const fileName of allFiles) {
if (!fileName.endsWith('/')) {
try {
const fileContent = await zip.file(fileName)?.async('string')
if (fileContent !== undefined) {
allFilesContent[fileName] = fileContent
// Set main code if this is the main file
if (fileName === mainFile) {
mainCode = fileContent
logger.info('Found main file content, length:', mainCode.length)
}
}
} catch (error) {
logger.warn(`Failed to extract file ${fileName}:`, error)
}
}
}
// If main file not found, try to find any code file
if (!mainCode) {
const codeFiles = Object.keys(allFilesContent).filter(
(file) =>
file.endsWith('.js') ||
file.endsWith('.py') ||
file.endsWith('.java') ||
file.endsWith('.cs') ||
file.endsWith('.go') ||
file.endsWith('.rb')
)
logger.info('Found code files:', codeFiles)
if (codeFiles.length > 0) {
const firstCodeFile = codeFiles[0]
mainCode = allFilesContent[firstCodeFile]
logger.info('Using first code file as main, length:', mainCode.length)
}
}
// If still no main code, use the first file
if (!mainCode && Object.keys(allFilesContent).length > 0) {
const firstFile = Object.keys(allFilesContent)[0]
mainCode = allFilesContent[firstFile]
logger.info('Using first file as main, length:', mainCode.length)
}
logger.info(`Extracted ${Object.keys(allFilesContent).length} files`)
return { mainCode, allFiles: allFilesContent }
} catch (error) {
logger.error('Failed to extract code from ZIP', { error })
return { mainCode: '', allFiles: {} }
}
}
/**
* Get detailed information about a Lambda function including code
*/
async function getFunctionDetailsWithCode(
lambdaClient: LambdaClient,
functionName: string,
region: string,
accessKeyId: string,
secretAccessKey: string
): Promise<LambdaFunctionDetails> {
// Get function configuration
const functionConfig = await lambdaClient.send(
new GetFunctionConfigurationCommand({ FunctionName: functionName })
)
// Get function code
const functionCode = await lambdaClient.send(
new GetFunctionCommand({ FunctionName: functionName })
)
let codeFiles: Record<string, string> = {}
if (functionCode.Code?.Location) {
try {
logger.info('Downloading code from:', functionCode.Code.Location)
const response = await fetch(functionCode.Code.Location)
logger.info('Fetch response status:', response.status)
if (response.ok) {
const zipBuffer = Buffer.from(await response.arrayBuffer())
logger.info('ZIP buffer size:', zipBuffer.length)
const extractedCode = await extractCodeFromZip(zipBuffer, functionConfig.Runtime || '')
codeFiles = extractedCode.allFiles
logger.info('Extracted files count:', Object.keys(codeFiles).length)
} else {
logger.warn('Fetch failed with status:', response.status)
const errorText = await response.text()
logger.warn('Error response:', errorText)
}
} catch (fetchError) {
logger.error('Failed to download function code using fetch', { fetchError })
}
} else {
logger.info('No code location found in function response')
}
return {
functionArn: functionConfig.FunctionArn || '',
functionName: functionConfig.FunctionName || '',
runtime: functionConfig.Runtime || '',
region,
status: functionConfig.State || '',
lastModified: functionConfig.LastModified || '',
codeSize: functionConfig.CodeSize || 0,
description: functionConfig.Description || '',
timeout: functionConfig.Timeout || 0,
memorySize: functionConfig.MemorySize || 0,
environment: functionConfig.Environment?.Variables || {},
tags: {}, // Tags need to be fetched separately if needed
codeFiles,
handler: functionConfig.Handler || '',
role: functionConfig.Role || '',
}
}
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.info(`[${requestId}] Processing AWS Lambda fetch request`)
// Parse and validate request body
let body: any
try {
body = await request.json()
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse request body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
})
return createErrorResponse('Invalid JSON in request body', 400, 'INVALID_JSON')
}
const validationResult = FetchRequestSchema.safeParse(body)
if (!validationResult.success) {
logger.warn(`[${requestId}] Invalid request body`, { errors: validationResult.error.errors })
return createErrorResponse('Invalid request parameters', 400, 'VALIDATION_ERROR')
}
const params = validationResult.data
// Log the payload (excluding sensitive credentials)
logger.info(`[${requestId}] AWS Lambda fetch payload received`, {
functionName: params.functionName,
region: params.region,
accessKeyId: params.accessKeyId ? `${params.accessKeyId.substring(0, 4)}...` : undefined,
hasSecretAccessKey: !!params.secretAccessKey,
hasFunctionName: !!params.functionName,
hasRole: !!params.role,
role: params.role ? `${params.role.substring(0, 20)}...` : undefined,
})
logger.info(`[${requestId}] Fetching Lambda function: ${params.functionName}`)
// Create Lambda client
const lambdaClient = new LambdaClient({
region: params.region,
credentials: {
accessKeyId: params.accessKeyId,
secretAccessKey: params.secretAccessKey,
},
})
// Fetch function details and code
try {
const functionDetails = await getFunctionDetailsWithCode(
lambdaClient,
params.functionName,
params.region,
params.accessKeyId,
params.secretAccessKey
)
logger.info(`[${requestId}] Successfully fetched Lambda function: ${params.functionName}`, {
functionName: functionDetails.functionName,
filesCount: Object.keys(functionDetails.codeFiles).length,
hasFiles: Object.keys(functionDetails.codeFiles).length > 0,
})
return createSuccessResponse({
success: true,
output: functionDetails,
})
} catch (fetchError: any) {
// Handle ResourceNotFoundException gracefully - return empty function details
if (fetchError.name === 'ResourceNotFoundException') {
logger.info(
`[${requestId}] Lambda function '${params.functionName}' not found, returning empty response`
)
const emptyFunctionDetails: LambdaFunctionDetails = {
functionArn: '',
functionName: params.functionName,
runtime: '',
region: params.region,
status: '',
lastModified: '',
codeSize: 0,
description: '',
timeout: 0,
memorySize: 0,
environment: {},
tags: {},
codeFiles: {},
handler: '',
role: '',
}
return createSuccessResponse({
success: true,
output: emptyFunctionDetails,
})
}
// Re-throw other errors to be handled by the outer catch block
throw fetchError
}
} catch (error: any) {
logger.error(`[${requestId}] Failed to fetch Lambda function`, {
error: error.message,
stack: error.stack,
})
// Handle specific AWS errors
// Note: ResourceNotFoundException is now handled gracefully in the inner try-catch
if (error.name === 'AccessDeniedException') {
return createErrorResponse(
'Access denied. Please check your AWS credentials and permissions.',
403,
'ACCESS_DENIED'
)
}
if (error.name === 'InvalidParameterValueException') {
return createErrorResponse('Invalid parameter value provided', 400, 'INVALID_PARAMETER')
}
return createErrorResponse('Failed to fetch Lambda function', 500, 'FETCH_ERROR')
}
}

View File

@@ -0,0 +1,91 @@
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('AWSLambdaGetPromptsAPI')
// Constants for getPrompts operation
const system_prompt = `You are an expert in writing aws lambda functions. The user will provide an input which may contain the the existing lambda code, or they may not. If the initial code is provided, make the changes to the initial code to reflect what the user wants. If no code is provided, your job is to write the lambda function, choosing a runtime and handler.
Your output should be a valid JSON object, with the following structure:
[
"runtime": runtime string,
"handler": handler,
"timeout": timeout,
"memory": memory,
"files":
{
"file_path_1": "code string for first file",
"file_path_2": "code string for second file"
}
]`
const schema = {
name: 'aws_lambda_function',
description: 'Defines the structure for an AWS Lambda function configuration.',
strict: true,
schema: {
type: 'object',
properties: {
runtime: {
type: 'string',
description: 'The runtime environment for the Lambda function.',
},
handler: {
type: 'string',
description: 'The function handler that Lambda calls to start execution.',
},
memory: {
type: 'integer',
description: 'The amount of memory allocated to the Lambda function in MB (128-10240).',
minimum: 128,
maximum: 10240,
},
timeout: {
type: 'integer',
description: 'The maximum execution time for the Lambda function in seconds (1-900).',
minimum: 1,
maximum: 900,
},
files: {
type: 'object',
description: 'A mapping of file paths to their respective code strings.',
additionalProperties: {
type: 'string',
description: 'The code string for a specific file.',
},
},
},
additionalProperties: false,
required: ['runtime', 'handler', 'files', 'memory', 'timeout'],
},
}
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.info(`[${requestId}] Processing AWS Lambda get prompts request`)
// No validation needed since this endpoint doesn't require any parameters
// Just return the hardcoded system prompt and schema
logger.info(`[${requestId}] Returning system prompt and schema`)
return createSuccessResponse({
success: true,
output: {
systemPrompt: system_prompt,
schema: schema,
},
})
} catch (error: any) {
logger.error(`[${requestId}] Error in get prompts operation`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return createErrorResponse('Failed to get prompts and schema', 500, 'GET_PROMPTS_ERROR')
}
}

View File

@@ -14,6 +14,7 @@ const SettingsSchema = z.object({
debugMode: z.boolean().optional(), debugMode: z.boolean().optional(),
autoConnect: z.boolean().optional(), autoConnect: z.boolean().optional(),
autoFillEnvVars: z.boolean().optional(), autoFillEnvVars: z.boolean().optional(),
autoPan: z.boolean().optional(),
telemetryEnabled: z.boolean().optional(), telemetryEnabled: z.boolean().optional(),
telemetryNotifiedUser: z.boolean().optional(), telemetryNotifiedUser: z.boolean().optional(),
emailPreferences: z emailPreferences: z
@@ -32,6 +33,7 @@ const defaultSettings = {
debugMode: false, debugMode: false,
autoConnect: true, autoConnect: true,
autoFillEnvVars: true, autoFillEnvVars: true,
autoPan: true,
telemetryEnabled: true, telemetryEnabled: true,
telemetryNotifiedUser: false, telemetryNotifiedUser: false,
emailPreferences: {}, emailPreferences: {},
@@ -65,6 +67,7 @@ export async function GET() {
debugMode: userSettings.debugMode, debugMode: userSettings.debugMode,
autoConnect: userSettings.autoConnect, autoConnect: userSettings.autoConnect,
autoFillEnvVars: userSettings.autoFillEnvVars, autoFillEnvVars: userSettings.autoFillEnvVars,
autoPan: userSettings.autoPan,
telemetryEnabled: userSettings.telemetryEnabled, telemetryEnabled: userSettings.telemetryEnabled,
telemetryNotifiedUser: userSettings.telemetryNotifiedUser, telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
emailPreferences: userSettings.emailPreferences ?? {}, emailPreferences: userSettings.emailPreferences ?? {},

View File

@@ -32,7 +32,6 @@ const executeMock = vi.fn().mockResolvedValue({
endTime: new Date().toISOString(), endTime: new Date().toISOString(),
}, },
}) })
const persistExecutionLogsMock = vi.fn().mockResolvedValue(undefined)
const persistExecutionErrorMock = vi.fn().mockResolvedValue(undefined) const persistExecutionErrorMock = vi.fn().mockResolvedValue(undefined)
// Mock the DB schema objects // Mock the DB schema objects
@@ -80,7 +79,6 @@ vi.mock('@/executor', () => ({
})) }))
vi.mock('@/lib/logs/execution-logger', () => ({ vi.mock('@/lib/logs/execution-logger', () => ({
persistExecutionLogs: persistExecutionLogsMock,
persistExecutionError: persistExecutionErrorMock, persistExecutionError: persistExecutionErrorMock,
})) }))

View File

@@ -31,6 +31,27 @@ describe('Workflow Deployment API Route', () => {
}), }),
})) }))
// Mock serializer
vi.doMock('@/serializer', () => ({
serializeWorkflow: vi.fn().mockReturnValue({
version: '1.0',
blocks: [
{
id: 'block-1',
metadata: { id: 'starter', name: 'Start' },
position: { x: 100, y: 100 },
config: { tool: 'starter', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [],
loops: {},
parallels: {},
}),
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({ vi.doMock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({ loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({
blocks: { blocks: {
@@ -75,6 +96,80 @@ describe('Workflow Deployment API Route', () => {
}) })
}), }),
})) }))
// Mock the database schema module
vi.doMock('@/db/schema', () => ({
workflow: {},
apiKey: {},
workflowBlocks: {},
workflowEdges: {},
workflowSubflows: {},
}))
// Mock drizzle-orm operators
vi.doMock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
and: vi.fn((...conditions) => ({ conditions, type: 'and' })),
}))
// Mock the database module with proper chainable query builder
let selectCallCount = 0
vi.doMock('@/db', () => ({
db: {
select: vi.fn().mockImplementation(() => {
selectCallCount++
return {
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
})),
})),
}
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
}) })
afterEach(() => { afterEach(() => {
@@ -126,16 +221,7 @@ describe('Workflow Deployment API Route', () => {
* This should generate a new API key * This should generate a new API key
*/ */
it('should create new API key when deploying workflow for user with no API key', async () => { it('should create new API key when deploying workflow for user with no API key', async () => {
const mockInsert = vi.fn().mockReturnValue({ // Override the global mock for this specific test
values: vi.fn().mockReturnValue(undefined),
})
const mockUpdate = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]),
}),
})
vi.doMock('@/db', () => ({ vi.doMock('@/db', () => ({
db: { db: {
select: vi select: vi
@@ -143,11 +229,7 @@ describe('Workflow Deployment API Route', () => {
.mockReturnValueOnce({ .mockReturnValueOnce({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([ limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
{
userId: 'user-id',
},
]),
}), }),
}), }),
}) })
@@ -184,8 +266,14 @@ describe('Workflow Deployment API Route', () => {
}), }),
}), }),
}), }),
insert: mockInsert, insert: vi.fn().mockImplementation(() => ({
update: mockUpdate, values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
}, },
})) }))
@@ -204,9 +292,6 @@ describe('Workflow Deployment API Route', () => {
expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345') expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345')
expect(data).toHaveProperty('isDeployed', true) expect(data).toHaveProperty('isDeployed', true)
expect(data).toHaveProperty('deployedAt') expect(data).toHaveProperty('deployedAt')
expect(mockInsert).toHaveBeenCalled()
expect(mockUpdate).toHaveBeenCalled()
}) })
/** /**
@@ -214,14 +299,7 @@ describe('Workflow Deployment API Route', () => {
* This should use the existing API key * This should use the existing API key
*/ */
it('should use existing API key when deploying workflow', async () => { it('should use existing API key when deploying workflow', async () => {
const mockInsert = vi.fn() // Override the global mock for this specific test
const mockUpdate = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]),
}),
})
vi.doMock('@/db', () => ({ vi.doMock('@/db', () => ({
db: { db: {
select: vi select: vi
@@ -229,11 +307,7 @@ describe('Workflow Deployment API Route', () => {
.mockReturnValueOnce({ .mockReturnValueOnce({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([ limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
{
userId: 'user-id',
},
]),
}), }),
}), }),
}) })
@@ -266,16 +340,18 @@ describe('Workflow Deployment API Route', () => {
.mockReturnValueOnce({ .mockReturnValueOnce({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([ limit: vi.fn().mockResolvedValue([{ key: 'sim_existingtestapikey12345' }]), // Existing API key
{
key: 'sim_existingtestapikey12345',
},
]), // Existing API key
}), }),
}), }),
}), }),
insert: mockInsert, insert: vi.fn().mockImplementation(() => ({
update: mockUpdate, values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
}, },
})) }))
@@ -293,9 +369,6 @@ describe('Workflow Deployment API Route', () => {
expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345') expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345')
expect(data).toHaveProperty('isDeployed', true) expect(data).toHaveProperty('isDeployed', true)
expect(mockInsert).not.toHaveBeenCalled()
expect(mockUpdate).toHaveBeenCalled()
}) })
/** /**

View File

@@ -88,6 +88,7 @@ describe('Workflow Execution API Route', () => {
vi.doMock('@/executor', () => ({ vi.doMock('@/executor', () => ({
Executor: vi.fn().mockImplementation(() => ({ Executor: vi.fn().mockImplementation(() => ({
execute: executeMock, execute: executeMock,
setEnhancedLogger: vi.fn(),
})), })),
})) }))
@@ -104,6 +105,14 @@ describe('Workflow Execution API Route', () => {
persistExecutionError: vi.fn().mockResolvedValue(undefined), persistExecutionError: vi.fn().mockResolvedValue(undefined),
})) }))
vi.doMock('@/lib/logs/enhanced-execution-logger', () => ({
enhancedExecutionLogger: {
startWorkflowExecution: vi.fn().mockResolvedValue(undefined),
logBlockExecution: vi.fn().mockResolvedValue(undefined),
completeWorkflowExecution: vi.fn().mockResolvedValue(undefined),
},
}))
vi.doMock('@/lib/logs/trace-spans', () => ({ vi.doMock('@/lib/logs/trace-spans', () => ({
buildTraceSpans: vi.fn().mockReturnValue({ buildTraceSpans: vi.fn().mockReturnValue({
traceSpans: [], traceSpans: [],
@@ -246,10 +255,7 @@ describe('Workflow Execution API Route', () => {
expect.anything(), // serializedWorkflow expect.anything(), // serializedWorkflow
expect.anything(), // processedBlockStates expect.anything(), // processedBlockStates
expect.anything(), // decryptedEnvVars expect.anything(), // decryptedEnvVars
expect.objectContaining({ requestBody, // processedInput (direct input, not wrapped)
// processedInput
input: requestBody,
}),
expect.anything() // workflowVariables expect.anything() // workflowVariables
) )
}) })
@@ -285,10 +291,7 @@ describe('Workflow Execution API Route', () => {
expect.anything(), // serializedWorkflow expect.anything(), // serializedWorkflow
expect.anything(), // processedBlockStates expect.anything(), // processedBlockStates
expect.anything(), // decryptedEnvVars expect.anything(), // decryptedEnvVars
expect.objectContaining({ structuredInput, // processedInput (direct input, not wrapped)
// processedInput
input: structuredInput,
}),
expect.anything() // workflowVariables expect.anything() // workflowVariables
) )
}) })
@@ -401,6 +404,7 @@ describe('Workflow Execution API Route', () => {
vi.doMock('@/executor', () => ({ vi.doMock('@/executor', () => ({
Executor: vi.fn().mockImplementation(() => ({ Executor: vi.fn().mockImplementation(() => ({
execute: vi.fn().mockRejectedValue(new Error('Execution failed')), execute: vi.fn().mockRejectedValue(new Error('Execution failed')),
setEnhancedLogger: vi.fn(),
})), })),
})) }))
@@ -424,10 +428,10 @@ describe('Workflow Execution API Route', () => {
expect(data).toHaveProperty('error') expect(data).toHaveProperty('error')
expect(data.error).toContain('Execution failed') expect(data.error).toContain('Execution failed')
// Verify error logger was called // Verify enhanced logger was called for error completion
const persistExecutionError = (await import('@/lib/logs/execution-logger')) const enhancedExecutionLogger = (await import('@/lib/logs/enhanced-execution-logger'))
.persistExecutionError .enhancedExecutionLogger
expect(persistExecutionError).toHaveBeenCalled() expect(enhancedExecutionLogger.completeWorkflowExecution).toHaveBeenCalled()
}) })
/** /**

View File

@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod' import { z } from 'zod'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { persistExecutionError, persistExecutionLogs } from '@/lib/logs/execution-logger' import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans' import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { checkServerSideUsageLimits } from '@/lib/usage-monitor' import { checkServerSideUsageLimits } from '@/lib/usage-monitor'
import { decryptSecret } from '@/lib/utils' import { decryptSecret } from '@/lib/utils'
@@ -14,11 +14,10 @@ import {
workflowHasResponseBlock, workflowHasResponseBlock,
} from '@/lib/workflows/utils' } from '@/lib/workflows/utils'
import { db } from '@/db' import { db } from '@/db'
import { environment, userStats } from '@/db/schema' import { environment as environmentTable, userStats } from '@/db/schema'
import { Executor } from '@/executor' import { Executor } from '@/executor'
import { Serializer } from '@/serializer' import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils' import { mergeSubblockState } from '@/stores/workflows/server-utils'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { validateWorkflowAccess } from '../../middleware' import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils' import { createErrorResponse, createSuccessResponse } from '../../utils'
@@ -59,6 +58,8 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
throw new Error('Execution is already running') throw new Error('Execution is already running')
} }
const loggingSession = new EnhancedLoggingSession(workflowId, executionId, 'api', requestId)
// Check if the user has exceeded their usage limits // Check if the user has exceeded their usage limits
const usageCheck = await checkServerSideUsageLimits(workflow.userId) const usageCheck = await checkServerSideUsageLimits(workflow.userId)
if (usageCheck.isExceeded) { if (usageCheck.isExceeded) {
@@ -77,19 +78,12 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
input ? JSON.stringify(input, null, 2) : 'No input provided' input ? JSON.stringify(input, null, 2) : 'No input provided'
) )
// Validate and structure input for maximum compatibility // Use input directly for API workflows
let processedInput = input const processedInput = input
if (input && typeof input === 'object') { logger.info(
// Ensure input is properly structured for the starter block `[${requestId}] Using input directly for workflow:`,
if (input.input === undefined) { JSON.stringify(processedInput, null, 2)
// If input is not already nested, structure it properly )
processedInput = { input: input }
logger.info(
`[${requestId}] Restructured input for workflow:`,
JSON.stringify(processedInput, null, 2)
)
}
}
try { try {
runningExecutions.add(executionKey) runningExecutions.add(executionKey)
@@ -99,39 +93,30 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
logger.debug(`[${requestId}] Loading workflow ${workflowId} from normalized tables`) logger.debug(`[${requestId}] Loading workflow ${workflowId} from normalized tables`)
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
let blocks: Record<string, any> if (!normalizedData) {
let edges: any[] throw new Error(
let loops: Record<string, any> `Workflow ${workflowId} has no normalized data available. Ensure the workflow is properly saved to normalized tables.`
let parallels: Record<string, any>
if (normalizedData) {
// Use normalized data as primary source
;({ blocks, edges, loops, parallels } = normalizedData)
logger.info(`[${requestId}] Using normalized tables for workflow execution: ${workflowId}`)
} else {
// Fallback to deployed state if available (for legacy workflows)
logger.warn(
`[${requestId}] No normalized data found, falling back to deployed state for workflow: ${workflowId}`
) )
if (!workflow.deployedState) {
throw new Error(
`Workflow ${workflowId} has no deployed state and no normalized data available`
)
}
const deployedState = workflow.deployedState as WorkflowState
;({ blocks, edges, loops, parallels } = deployedState)
} }
// Use normalized data as primary source
const { blocks, edges, loops, parallels } = normalizedData
logger.info(`[${requestId}] Using normalized tables for workflow execution: ${workflowId}`)
logger.debug(`[${requestId}] Normalized data loaded:`, {
blocksCount: Object.keys(blocks || {}).length,
edgesCount: (edges || []).length,
loopsCount: Object.keys(loops || {}).length,
parallelsCount: Object.keys(parallels || {}).length,
})
// Use the same execution flow as in scheduled executions // Use the same execution flow as in scheduled executions
const mergedStates = mergeSubblockState(blocks) const mergedStates = mergeSubblockState(blocks)
// Fetch the user's environment variables (if any) // Fetch the user's environment variables (if any)
const [userEnv] = await db const [userEnv] = await db
.select() .select()
.from(environment) .from(environmentTable)
.where(eq(environment.userId, workflow.userId)) .where(eq(environmentTable.userId, workflow.userId))
.limit(1) .limit(1)
if (!userEnv) { if (!userEnv) {
@@ -140,9 +125,14 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
) )
} }
// Parse and validate environment variables.
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {}) const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
await loggingSession.safeStart({
userId: workflow.userId,
workspaceId: workflow.workspaceId,
variables,
})
// Replace environment variables in the block states // Replace environment variables in the block states
const currentBlockStates = await Object.entries(mergedStates).reduce( const currentBlockStates = await Object.entries(mergedStates).reduce(
async (accPromise, [id, block]) => { async (accPromise, [id, block]) => {
@@ -207,18 +197,42 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
(acc, [blockId, blockState]) => { (acc, [blockId, blockState]) => {
// Check if this block has a responseFormat that needs to be parsed // Check if this block has a responseFormat that needs to be parsed
if (blockState.responseFormat && typeof blockState.responseFormat === 'string') { if (blockState.responseFormat && typeof blockState.responseFormat === 'string') {
try { const responseFormatValue = blockState.responseFormat.trim()
logger.debug(`[${requestId}] Parsing responseFormat for block ${blockId}`)
// Attempt to parse the responseFormat if it's a string
const parsedResponseFormat = JSON.parse(blockState.responseFormat)
// Check for variable references like <start.input>
if (responseFormatValue.startsWith('<') && responseFormatValue.includes('>')) {
logger.debug(
`[${requestId}] Response format contains variable reference for block ${blockId}`
)
// Keep variable references as-is - they will be resolved during execution
acc[blockId] = blockState
} else if (responseFormatValue === '') {
// Empty string - remove response format
acc[blockId] = { acc[blockId] = {
...blockState, ...blockState,
responseFormat: parsedResponseFormat, responseFormat: undefined,
}
} else {
try {
logger.debug(`[${requestId}] Parsing responseFormat for block ${blockId}`)
// Attempt to parse the responseFormat if it's a string
const parsedResponseFormat = JSON.parse(responseFormatValue)
acc[blockId] = {
...blockState,
responseFormat: parsedResponseFormat,
}
} catch (error) {
logger.warn(
`[${requestId}] Failed to parse responseFormat for block ${blockId}, using undefined`,
error
)
// Set to undefined instead of keeping malformed JSON - this allows execution to continue
acc[blockId] = {
...blockState,
responseFormat: undefined,
}
} }
} catch (error) {
logger.warn(`[${requestId}] Failed to parse responseFormat for block ${blockId}`, error)
acc[blockId] = blockState
} }
} else { } else {
acc[blockId] = blockState acc[blockId] = blockState
@@ -267,6 +281,9 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
workflowVariables workflowVariables
) )
// Set up enhanced logging on the executor
loggingSession.setupExecutor(executor)
const result = await executor.execute(workflowId) const result = await executor.execute(workflowId)
// Check if we got a StreamingExecution result (with stream + execution properties) // Check if we got a StreamingExecution result (with stream + execution properties)
@@ -278,6 +295,9 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
executionTime: executionResult.metadata?.duration, executionTime: executionResult.metadata?.duration,
}) })
// Build trace spans from execution result (works for both success and failure)
const { traceSpans, totalDuration } = buildTraceSpans(executionResult)
// Update workflow run counts if execution was successful // Update workflow run counts if execution was successful
if (executionResult.success) { if (executionResult.success) {
await updateWorkflowRunCounts(workflowId) await updateWorkflowRunCounts(workflowId)
@@ -292,24 +312,26 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
.where(eq(userStats.userId, workflow.userId)) .where(eq(userStats.userId, workflow.userId))
} }
// Build trace spans from execution logs await loggingSession.safeComplete({
const { traceSpans, totalDuration } = buildTraceSpans(executionResult) endedAt: new Date().toISOString(),
totalDurationMs: totalDuration || 0,
// Add trace spans to the execution result finalOutput: executionResult.output || {},
const enrichedResult = { traceSpans: (traceSpans || []) as any,
...executionResult, })
traceSpans,
totalDuration,
}
// Log each execution step and the final result
await persistExecutionLogs(workflowId, executionId, enrichedResult, 'api')
return executionResult return executionResult
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, error) logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, error)
// Log the error
await persistExecutionError(workflowId, executionId, error, 'api') await loggingSession.safeCompleteWithError({
endedAt: new Date().toISOString(),
totalDurationMs: 0,
error: {
message: error.message || 'Workflow execution failed',
stackTrace: error.stack,
},
})
throw error throw error
} finally { } finally {
runningExecutions.delete(executionKey) runningExecutions.delete(executionKey)
@@ -381,13 +403,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] No request body provided`) logger.info(`[${requestId}] No request body provided`)
} }
// Don't double-nest the input if it's already structured // Pass the raw body directly as input for API workflows
const hasContent = Object.keys(body).length > 0 const hasContent = Object.keys(body).length > 0
const input = hasContent ? { input: body } : {} const input = hasContent ? body : {}
logger.info(`[${requestId}] Input passed to workflow:`, JSON.stringify(input, null, 2)) logger.info(`[${requestId}] Input passed to workflow:`, JSON.stringify(input, null, 2))
// Execute workflow with the structured input // Execute workflow with the raw input
const result = await executeWorkflow(validation.workflow, requestId, input) const result = await executeWorkflow(validation.workflow, requestId, input)
// Check if the workflow execution contains a response block output // Check if the workflow execution contains a response block output

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { persistExecutionLogs, persistLog } from '@/lib/logs/execution-logger' import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { validateWorkflowAccess } from '../../middleware' import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils' import { createErrorResponse, createSuccessResponse } from '../../utils'
@@ -33,9 +33,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Check if this execution is from chat using only the explicit source flag // Check if this execution is from chat using only the explicit source flag
const isChatExecution = result.metadata?.source === 'chat' const isChatExecution = result.metadata?.source === 'chat'
// Use persistExecutionLogs which handles tool call extraction // Also log to enhanced system
// Use 'chat' trigger type for chat executions, otherwise 'manual' const triggerType = isChatExecution ? 'chat' : 'manual'
await persistExecutionLogs(id, executionId, result, isChatExecution ? 'chat' : 'manual') const loggingSession = new EnhancedLoggingSession(id, executionId, triggerType, requestId)
await loggingSession.safeStart({
userId: '', // TODO: Get from session
workspaceId: '', // TODO: Get from workflow
variables: {},
})
// Build trace spans from execution logs
const { traceSpans } = buildTraceSpans(result)
await loggingSession.safeComplete({
endedAt: new Date().toISOString(),
totalDurationMs: result.metadata?.duration || 0,
finalOutput: result.output || {},
traceSpans,
})
return createSuccessResponse({ return createSuccessResponse({
message: 'Execution logs persisted successfully', message: 'Execution logs persisted successfully',
@@ -52,21 +68,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
executionId, executionId,
}) })
// Persist each log using the original method
for (const log of logs) {
await persistLog({
id: uuidv4(),
workflowId: id,
executionId,
level: log.level,
message: log.message,
duration: log.duration,
trigger: log.trigger || 'manual',
createdAt: new Date(log.createdAt || new Date()),
metadata: log.metadata,
})
}
return createSuccessResponse({ message: 'Logs persisted successfully' }) return createSuccessResponse({ message: 'Logs persisted successfully' })
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Error persisting logs for workflow: ${id}`, error) logger.error(`[${requestId}] Error persisting logs for workflow: ${id}`, error)

View File

@@ -0,0 +1,121 @@
import crypto from 'crypto'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db'
import { workflow } from '@/db/schema'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils'
const logger = createLogger('RevertToDeployedAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
/**
* POST /api/workflows/[id]/revert-to-deployed
* Revert workflow to its deployed state by saving deployed state to normalized tables
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const { id } = await params
try {
logger.debug(`[${requestId}] Reverting workflow to deployed state: ${id}`)
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Workflow revert failed: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
}
const workflowData = validation.workflow
// Check if workflow is deployed and has deployed state
if (!workflowData.isDeployed || !workflowData.deployedState) {
logger.warn(`[${requestId}] Cannot revert: workflow is not deployed or has no deployed state`)
return createErrorResponse('Workflow is not deployed or has no deployed state', 400)
}
// Validate deployed state structure
const deployedState = workflowData.deployedState as WorkflowState
if (!deployedState.blocks || !deployedState.edges) {
logger.error(`[${requestId}] Invalid deployed state structure`, { deployedState })
return createErrorResponse('Invalid deployed state structure', 500)
}
logger.debug(`[${requestId}] Saving deployed state to normalized tables`, {
blocksCount: Object.keys(deployedState.blocks).length,
edgesCount: deployedState.edges.length,
loopsCount: Object.keys(deployedState.loops || {}).length,
parallelsCount: Object.keys(deployedState.parallels || {}).length,
})
// Save deployed state to normalized tables
const saveResult = await saveWorkflowToNormalizedTables(id, {
blocks: deployedState.blocks,
edges: deployedState.edges,
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
deploymentStatuses: deployedState.deploymentStatuses || {},
hasActiveSchedule: deployedState.hasActiveSchedule || false,
hasActiveWebhook: deployedState.hasActiveWebhook || false,
})
if (!saveResult.success) {
logger.error(`[${requestId}] Failed to save deployed state to normalized tables`, {
error: saveResult.error,
})
return createErrorResponse(
saveResult.error || 'Failed to save deployed state to normalized tables',
500
)
}
// Update workflow's last_synced timestamp to indicate changes
await db
.update(workflow)
.set({
lastSynced: new Date(),
updatedAt: new Date(),
})
.where(eq(workflow.id, id))
// Notify socket server about the revert operation for real-time sync
try {
const socketServerUrl = process.env.SOCKET_SERVER_URL || 'http://localhost:3002'
await fetch(`${socketServerUrl}/api/workflow-reverted`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workflowId: id,
timestamp: Date.now(),
}),
})
logger.debug(`[${requestId}] Notified socket server about workflow revert: ${id}`)
} catch (socketError) {
// Don't fail the request if socket notification fails
logger.warn(`[${requestId}] Failed to notify socket server about revert:`, socketError)
}
logger.info(`[${requestId}] Successfully reverted workflow to deployed state: ${id}`)
return createSuccessResponse({
message: 'Workflow successfully reverted to deployed state',
lastSaved: Date.now(),
})
} catch (error: any) {
logger.error(`[${requestId}] Error reverting workflow to deployed state: ${id}`, {
error: error.message,
stack: error.stack,
})
return createErrorResponse(error.message || 'Failed to revert workflow to deployed state', 500)
}
}

View File

@@ -274,14 +274,6 @@ describe('Workflow By ID API Route', () => {
}), }),
})) }))
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
await callback({
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
})
})
vi.doMock('@/db', () => ({ vi.doMock('@/db', () => ({
db: { db: {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
@@ -291,7 +283,9 @@ describe('Workflow By ID API Route', () => {
}), }),
}), }),
}), }),
transaction: mockTransaction, delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
}, },
})) }))
@@ -326,14 +320,6 @@ describe('Workflow By ID API Route', () => {
}), }),
})) }))
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
await callback({
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
})
})
vi.doMock('@/db', () => ({ vi.doMock('@/db', () => ({
db: { db: {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
@@ -343,7 +329,9 @@ describe('Workflow By ID API Route', () => {
}), }),
}), }),
}), }),
transaction: mockTransaction, delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
}, },
})) }))

View File

@@ -2,11 +2,13 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { verifyInternalToken } from '@/lib/auth/internal'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils' import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db' import { db } from '@/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema' import { workflow } from '@/db/schema'
const logger = createLogger('WorkflowByIdAPI') const logger = createLogger('WorkflowByIdAPI')
@@ -28,14 +30,29 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { id: workflowId } = await params const { id: workflowId } = await params
try { try {
// Get the session // Check for internal JWT token for server-side calls
const session = await getSession() const authHeader = request.headers.get('authorization')
if (!session?.user?.id) { let isInternalCall = false
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
isInternalCall = await verifyInternalToken(token)
} }
const userId = session.user.id let userId: string | null = null
if (isInternalCall) {
// For internal calls, we'll skip user-specific access checks
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
} else {
// Get the session for regular user calls
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
userId = session.user.id
}
// Fetch the workflow // Fetch the workflow
const workflowData = await db const workflowData = await db
@@ -52,26 +69,31 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Check if user has access to this workflow // Check if user has access to this workflow
let hasAccess = false let hasAccess = false
// Case 1: User owns the workflow if (isInternalCall) {
if (workflowData.userId === userId) { // Internal calls have full access
hasAccess = true hasAccess = true
} } else {
// Case 1: User owns the workflow
// Case 2: Workflow belongs to a workspace the user has permissions for if (workflowData.userId === userId) {
if (!hasAccess && workflowData.workspaceId) {
const userPermission = await getUserEntityPermissions(
userId,
'workspace',
workflowData.workspaceId
)
if (userPermission !== null) {
hasAccess = true hasAccess = true
} }
}
if (!hasAccess) { // Case 2: Workflow belongs to a workspace the user has permissions for
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`) if (!hasAccess && workflowData.workspaceId && userId) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) const userPermission = await getUserEntityPermissions(
userId,
'workspace',
workflowData.workspaceId
)
if (userPermission !== null) {
hasAccess = true
}
}
if (!hasAccess) {
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
} }
// Try to load from normalized tables first // Try to load from normalized tables first
@@ -185,16 +207,7 @@ export async function DELETE(
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) return NextResponse.json({ error: 'Access denied' }, { status: 403 })
} }
// Delete workflow and all related data in a transaction await db.delete(workflow).where(eq(workflow.id, workflowId))
await db.transaction(async (tx) => {
// Delete from normalized tables first (foreign key constraints)
await tx.delete(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId))
await tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId))
await tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId))
// Delete the main workflow record
await tx.delete(workflow).where(eq(workflow.id, workflowId))
})
const elapsed = Date.now() - startTime const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`)
@@ -203,7 +216,7 @@ export async function DELETE(
// This prevents "Block not found" errors when collaborative updates try to process // This prevents "Block not found" errors when collaborative updates try to process
// after the workflow has been deleted // after the workflow has been deleted
try { try {
const socketUrl = process.env.SOCKET_SERVER_URL || 'http://localhost:3002' const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
const socketResponse = await fetch(`${socketUrl}/api/workflow-deleted`, { const socketResponse = await fetch(`${socketUrl}/api/workflow-deleted`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -2,13 +2,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { import { workflow, workspaceMember } from '@/db/schema'
workflow,
workflowBlocks,
workflowEdges,
workflowSubflows,
workspaceMember,
} from '@/db/schema'
const logger = createLogger('WorkspaceByIdAPI') const logger = createLogger('WorkspaceByIdAPI')
@@ -26,9 +20,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const workspaceId = id const workspaceId = id
// Check if user has read access to this workspace // Check if user has any access to this workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (userPermission !== 'read') { if (!userPermission) {
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
} }
@@ -126,20 +120,10 @@ export async function DELETE(
// Delete workspace and all related data in a transaction // Delete workspace and all related data in a transaction
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
// Get all workflows in this workspace // Delete all workflows in the workspace - database cascade will handle all workflow-related data
const workspaceWorkflows = await tx // The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows,
.select({ id: workflow.id }) // workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans,
.from(workflow) // workflow_schedule, webhook, marketplace, chat, and memory records
.where(eq(workflow.workspaceId, workspaceId))
// Delete all workflow-related data for each workflow
for (const wf of workspaceWorkflows) {
await tx.delete(workflowSubflows).where(eq(workflowSubflows.workflowId, wf.id))
await tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, wf.id))
await tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, wf.id))
}
// Delete all workflows in the workspace
await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId)) await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId))
// Delete workspace members // Delete workspace members

View File

@@ -60,7 +60,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ invitations }) return NextResponse.json({ invitations })
} catch (error) { } catch (error) {
console.error('Error fetching workspace invitations:', error) logger.error('Error fetching workspace invitations:', error)
return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 })
} }
} }
@@ -204,7 +204,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, invitation: invitationData }) return NextResponse.json({ success: true, invitation: invitationData })
} catch (error) { } catch (error) {
console.error('Error creating workspace invitation:', error) logger.error('Error creating workspace invitation:', error)
return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 })
} }
} }
@@ -252,9 +252,9 @@ async function sendInvitationEmail({
html: emailHtml, html: emailHtml,
}) })
console.log(`Invitation email sent to ${to}`) logger.info(`Invitation email sent to ${to}`)
} catch (error) { } catch (error) {
console.error('Error sending invitation email:', error) logger.error('Error sending invitation email:', error)
// Continue even if email fails - the invitation is still created // Continue even if email fails - the invitation is still created
} }
} }

View File

@@ -104,7 +104,7 @@ async function createWorkspace(userId: string, name: string) {
updatedAt: now, updatedAt: now,
}) })
// Create "Workflow 1" for the workspace with start block // Create initial workflow for the workspace with start block
const starterId = crypto.randomUUID() const starterId = crypto.randomUUID()
const initialState = { const initialState = {
blocks: { blocks: {
@@ -170,7 +170,7 @@ async function createWorkspace(userId: string, name: string) {
userId, userId,
workspaceId, workspaceId,
folderId: null, folderId: null,
name: 'Workflow 1', name: 'default-agent',
description: 'Your first workflow - start building here!', description: 'Your first workflow - start building here!',
state: initialState, state: initialState,
color: '#3972F6', color: '#3972F6',

View File

@@ -297,7 +297,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
try { try {
// Send structured payload to maintain chat context // Send structured payload to maintain chat context
const payload = { const payload = {
message: input:
typeof userMessage.content === 'string' typeof userMessage.content === 'string'
? userMessage.content ? userMessage.content
: JSON.stringify(userMessage.content), : JSON.stringify(userMessage.content),

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { AlertCircle, Loader2, X } from 'lucide-react' import { AlertCircle, ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store' import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
@@ -28,6 +29,12 @@ interface EditChunkModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onChunkUpdate?: (updatedChunk: ChunkData) => void onChunkUpdate?: (updatedChunk: ChunkData) => void
// New props for navigation
allChunks?: ChunkData[]
currentPage?: number
totalPages?: number
onNavigateToChunk?: (chunk: ChunkData) => void
onNavigateToPage?: (page: number, selectChunk: 'first' | 'last') => Promise<void>
} }
export function EditChunkModal({ export function EditChunkModal({
@@ -37,11 +44,18 @@ export function EditChunkModal({
isOpen, isOpen,
onClose, onClose,
onChunkUpdate, onChunkUpdate,
allChunks = [],
currentPage = 1,
totalPages = 1,
onNavigateToChunk,
onNavigateToPage,
}: EditChunkModalProps) { }: EditChunkModalProps) {
const [editedContent, setEditedContent] = useState(chunk?.content || '') const [editedContent, setEditedContent] = useState(chunk?.content || '')
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isNavigating, setIsNavigating] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
// Check if there are unsaved changes // Check if there are unsaved changes
const hasUnsavedChanges = editedContent !== (chunk?.content || '') const hasUnsavedChanges = editedContent !== (chunk?.content || '')
@@ -53,6 +67,13 @@ export function EditChunkModal({
} }
}, [chunk?.id, chunk?.content]) }, [chunk?.id, chunk?.content])
// Find current chunk index in the current page
const currentChunkIndex = chunk ? allChunks.findIndex((c) => c.id === chunk.id) : -1
// Calculate navigation availability
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
const handleSaveContent = async () => { const handleSaveContent = async () => {
if (!chunk || !document) return if (!chunk || !document) return
@@ -82,7 +103,6 @@ export function EditChunkModal({
if (result.success && onChunkUpdate) { if (result.success && onChunkUpdate) {
onChunkUpdate(result.data) onChunkUpdate(result.data)
onClose()
} }
} catch (err) { } catch (err) {
logger.error('Error updating chunk:', err) logger.error('Error updating chunk:', err)
@@ -92,8 +112,51 @@ export function EditChunkModal({
} }
} }
const navigateToChunk = async (direction: 'prev' | 'next') => {
if (!chunk || isNavigating) return
try {
setIsNavigating(true)
if (direction === 'prev') {
if (currentChunkIndex > 0) {
// Navigate to previous chunk in current page
const prevChunk = allChunks[currentChunkIndex - 1]
onNavigateToChunk?.(prevChunk)
} else if (currentPage > 1) {
// Load previous page and navigate to last chunk
await onNavigateToPage?.(currentPage - 1, 'last')
}
} else {
if (currentChunkIndex < allChunks.length - 1) {
// Navigate to next chunk in current page
const nextChunk = allChunks[currentChunkIndex + 1]
onNavigateToChunk?.(nextChunk)
} else if (currentPage < totalPages) {
// Load next page and navigate to first chunk
await onNavigateToPage?.(currentPage + 1, 'first')
}
}
} catch (err) {
logger.error(`Error navigating ${direction}:`, err)
setError(`Failed to navigate to ${direction === 'prev' ? 'previous' : 'next'} chunk`)
} finally {
setIsNavigating(false)
}
}
const handleNavigate = (direction: 'prev' | 'next') => {
if (hasUnsavedChanges) {
setPendingNavigation(() => () => navigateToChunk(direction))
setShowUnsavedChangesAlert(true)
} else {
void navigateToChunk(direction)
}
}
const handleCloseAttempt = () => { const handleCloseAttempt = () => {
if (hasUnsavedChanges && !isSaving) { if (hasUnsavedChanges && !isSaving) {
setPendingNavigation(null)
setShowUnsavedChangesAlert(true) setShowUnsavedChangesAlert(true)
} else { } else {
onClose() onClose()
@@ -102,7 +165,12 @@ export function EditChunkModal({
const handleConfirmDiscard = () => { const handleConfirmDiscard = () => {
setShowUnsavedChangesAlert(false) setShowUnsavedChangesAlert(false)
onClose() if (pendingNavigation) {
void pendingNavigation()
setPendingNavigation(null)
} else {
onClose()
}
} }
const isFormValid = editedContent.trim().length > 0 && editedContent.trim().length <= 10000 const isFormValid = editedContent.trim().length > 0 && editedContent.trim().length <= 10000
@@ -118,7 +186,59 @@ export function EditChunkModal({
> >
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'> <DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Edit Chunk</DialogTitle> <div className='flex items-center gap-3'>
<DialogTitle className='font-medium text-lg'>Edit Chunk</DialogTitle>
{/* Navigation Controls */}
<div className='flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
asChild
onFocus={(e) => e.preventDefault()}
onBlur={(e) => e.preventDefault()}
>
<Button
variant='ghost'
size='sm'
onClick={() => handleNavigate('prev')}
disabled={!canNavigatePrev || isNavigating || isSaving}
className='h-8 w-8 p-0'
>
<ChevronUp className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom'>
Previous chunk{' '}
{currentPage > 1 && currentChunkIndex === 0 ? '(previous page)' : ''}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
asChild
onFocus={(e) => e.preventDefault()}
onBlur={(e) => e.preventDefault()}
>
<Button
variant='ghost'
size='sm'
onClick={() => handleNavigate('next')}
disabled={!canNavigateNext || isNavigating || isSaving}
className='h-8 w-8 p-0'
>
<ChevronDown className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom'>
Next chunk{' '}
{currentPage < totalPages && currentChunkIndex === allChunks.length - 1
? '(next page)'
: ''}
</TooltipContent>
</Tooltip>
</div>
</div>
<Button <Button
variant='ghost' variant='ghost'
size='icon' size='icon'
@@ -142,7 +262,7 @@ export function EditChunkModal({
{document?.filename || 'Unknown Document'} {document?.filename || 'Unknown Document'}
</p> </p>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
Editing chunk #{chunk.chunkIndex} Editing chunk #{chunk.chunkIndex} Page {currentPage} of {totalPages}
</p> </p>
</div> </div>
</div> </div>
@@ -167,7 +287,7 @@ export function EditChunkModal({
onChange={(e) => setEditedContent(e.target.value)} onChange={(e) => setEditedContent(e.target.value)}
placeholder='Enter chunk content...' placeholder='Enter chunk content...'
className='flex-1 resize-none' className='flex-1 resize-none'
disabled={isSaving} disabled={isSaving || isNavigating}
/> />
</div> </div>
</div> </div>
@@ -176,12 +296,16 @@ export function EditChunkModal({
{/* Footer */} {/* Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'> <div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-between'> <div className='flex justify-between'>
<Button variant='outline' onClick={handleCloseAttempt} disabled={isSaving}> <Button
variant='outline'
onClick={handleCloseAttempt}
disabled={isSaving || isNavigating}
>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSaveContent} onClick={handleSaveContent}
disabled={!isFormValid || isSaving || !hasUnsavedChanges} disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}
className='bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]' className='bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
> >
{isSaving ? ( {isSaving ? (
@@ -205,12 +329,19 @@ export function EditChunkModal({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle> <AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
You have unsaved changes to this chunk content. Are you sure you want to discard your You have unsaved changes to this chunk content.
changes and close the editor? {pendingNavigation
? ' Do you want to discard your changes and navigate to the next chunk?'
: ' Are you sure you want to discard your changes and close the editor?'}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowUnsavedChangesAlert(false)}> <AlertDialogCancel
onClick={() => {
setShowUnsavedChangesAlert(false)
setPendingNavigation(null)
}}
>
Keep Editing Keep Editing
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction

View File

@@ -767,6 +767,30 @@ export function Document({
updateChunk(updatedChunk.id, updatedChunk) updateChunk(updatedChunk.id, updatedChunk)
setSelectedChunk(updatedChunk) setSelectedChunk(updatedChunk)
}} }}
allChunks={chunks}
currentPage={currentPage}
totalPages={totalPages}
onNavigateToChunk={(chunk: ChunkData) => {
setSelectedChunk(chunk)
}}
onNavigateToPage={async (page: number, selectChunk: 'first' | 'last') => {
await goToPage(page)
const checkAndSelectChunk = () => {
if (!isLoadingChunks && chunks.length > 0) {
if (selectChunk === 'first') {
setSelectedChunk(chunks[0])
} else {
setSelectedChunk(chunks[chunks.length - 1])
}
} else {
// Retry after a short delay if chunks aren't loaded yet
setTimeout(checkAndSelectChunk, 100)
}
}
setTimeout(checkAndSelectChunk, 0)
}}
/> />
{/* Create Chunk Modal */} {/* Create Chunk Modal */}

View File

@@ -36,16 +36,11 @@ import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowled
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
import { useSidebarStore } from '@/stores/sidebar/store' import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from '../components/knowledge-header/knowledge-header' import { KnowledgeHeader } from '../components/knowledge-header/knowledge-header'
import { useKnowledgeUpload } from '../hooks/use-knowledge-upload'
import { KnowledgeBaseLoading } from './components/knowledge-base-loading/knowledge-base-loading' import { KnowledgeBaseLoading } from './components/knowledge-base-loading/knowledge-base-loading'
const logger = createLogger('KnowledgeBase') const logger = createLogger('KnowledgeBase')
interface ProcessedDocumentResponse {
documentId: string
filename: string
status: string
}
interface KnowledgeBaseProps { interface KnowledgeBaseProps {
id: string id: string
knowledgeBaseName?: string knowledgeBaseName?: string
@@ -145,17 +140,32 @@ export function KnowledgeBase({
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isBulkOperating, setIsBulkOperating] = useState(false) const [isBulkOperating, setIsBulkOperating] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [uploadError, setUploadError] = useState<{ const { isUploading, uploadProgress, uploadError, uploadFiles, clearError } = useKnowledgeUpload({
message: string onUploadComplete: async (uploadedFiles) => {
timestamp: number const pendingDocuments: DocumentData[] = uploadedFiles.map((file, index) => ({
} | null>(null) id: `temp-${Date.now()}-${index}`,
const [uploadProgress, setUploadProgress] = useState<{ knowledgeBaseId: id,
stage: 'idle' | 'uploading' | 'processing' | 'completing' filename: file.filename,
filesCompleted: number fileUrl: file.fileUrl,
totalFiles: number fileSize: file.fileSize,
currentFile?: string mimeType: file.mimeType,
}>({ stage: 'idle', filesCompleted: 0, totalFiles: 0 }) chunkCount: 0,
tokenCount: 0,
characterCount: 0,
processingStatus: 'pending' as const,
processingStartedAt: null,
processingCompletedAt: null,
processingError: null,
enabled: true,
uploadedAt: new Date().toISOString(),
}))
useKnowledgeStore.getState().addPendingDocuments(id, pendingDocuments)
await refreshDocuments()
},
})
const router = useRouter() const router = useRouter()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -240,11 +250,11 @@ export function KnowledgeBase({
useEffect(() => { useEffect(() => {
if (uploadError) { if (uploadError) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setUploadError(null) clearError()
}, 8000) }, 8000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [uploadError]) }, [uploadError, clearError])
// Filter documents based on search query // Filter documents based on search query
const filteredDocuments = documents.filter((doc) => const filteredDocuments = documents.filter((doc) =>
@@ -448,153 +458,18 @@ export function KnowledgeBase({
const files = e.target.files const files = e.target.files
if (!files || files.length === 0) return if (!files || files.length === 0) return
interface UploadedFile {
filename: string
fileUrl: string
fileSize: number
mimeType: string
}
try { try {
setIsUploading(true) const chunkingConfig = knowledgeBase?.chunkingConfig
setUploadError(null) await uploadFiles(Array.from(files), id, {
setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length }) chunkSize: chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: chunkingConfig?.minSize || 100,
// Upload all files and start processing chunkOverlap: chunkingConfig?.overlap || 200,
const uploadedFiles: UploadedFile[] = [] recipe: 'default',
const fileArray = Array.from(files)
for (const [index, file] of fileArray.entries()) {
setUploadProgress((prev) => ({ ...prev, currentFile: file.name, filesCompleted: index }))
const formData = new FormData()
formData.append('file', file)
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
})
if (!uploadResponse.ok) {
const errorData = await uploadResponse.json()
throw new Error(`Failed to upload ${file.name}: ${errorData.error || 'Unknown error'}`)
}
const uploadResult = await uploadResponse.json()
// Validate upload result structure
if (!uploadResult.path) {
throw new Error(`Invalid upload response for ${file.name}: missing file path`)
}
uploadedFiles.push({
filename: file.name,
fileUrl: uploadResult.path.startsWith('http')
? uploadResult.path
: `${window.location.origin}${uploadResult.path}`,
fileSize: file.size,
mimeType: file.type,
})
}
setUploadProgress((prev) => ({
...prev,
stage: 'processing',
filesCompleted: fileArray.length,
}))
// Start async document processing
const processResponse = await fetch(`/api/knowledge/${id}/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
documents: uploadedFiles,
processingOptions: {
chunkSize: knowledgeBase?.chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: knowledgeBase?.chunkingConfig?.minSize || 100,
chunkOverlap: knowledgeBase?.chunkingConfig?.overlap || 200,
recipe: 'default',
lang: 'en',
},
bulk: true,
}),
}) })
} catch (error) {
if (!processResponse.ok) { logger.error('Error uploading files:', error)
const errorData = await processResponse.json() // Error handling is managed by the upload hook
throw new Error(
`Failed to start document processing: ${errorData.error || 'Unknown error'}`
)
}
const processResult = await processResponse.json()
// Validate process result structure
if (!processResult.success) {
throw new Error(`Document processing failed: ${processResult.error || 'Unknown error'}`)
}
if (!processResult.data || !processResult.data.documentsCreated) {
throw new Error('Invalid processing response: missing document data')
}
// Create pending document objects and add them to the store immediately
const pendingDocuments: DocumentData[] = processResult.data.documentsCreated.map(
(doc: ProcessedDocumentResponse, index: number) => {
if (!doc.documentId || !doc.filename) {
logger.error(`Invalid document data received:`, doc)
throw new Error(
`Invalid document data for ${uploadedFiles[index]?.filename || 'unknown file'}`
)
}
return {
id: doc.documentId,
knowledgeBaseId: id,
filename: doc.filename,
fileUrl: uploadedFiles[index].fileUrl,
fileSize: uploadedFiles[index].fileSize,
mimeType: uploadedFiles[index].mimeType,
chunkCount: 0,
tokenCount: 0,
characterCount: 0,
processingStatus: 'pending' as const,
processingStartedAt: null,
processingCompletedAt: null,
processingError: null,
enabled: true,
uploadedAt: new Date().toISOString(),
}
}
)
// Add pending documents to store for immediate UI update
useKnowledgeStore.getState().addPendingDocuments(id, pendingDocuments)
logger.info(`Successfully started processing ${uploadedFiles.length} documents`)
setUploadProgress((prev) => ({ ...prev, stage: 'completing' }))
// Trigger a refresh to ensure documents are properly loaded
await refreshDocuments()
setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 })
} catch (err) {
logger.error('Error uploading documents:', err)
const errorMessage =
err instanceof Error ? err.message : 'Unknown error occurred during upload'
setUploadError({
message: errorMessage,
timestamp: Date.now(),
})
// Show user-friendly error message in console for debugging
console.error('Document upload failed:', errorMessage)
} finally { } finally {
setIsUploading(false)
setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 })
// Reset the file input // Reset the file input
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = '' fileInputRef.current.value = ''
@@ -995,7 +870,7 @@ export function KnowledgeBase({
</tr> </tr>
)) ))
) : ( ) : (
filteredDocuments.map((doc, index) => { filteredDocuments.map((doc) => {
const isSelected = selectedDocuments.has(doc.id) const isSelected = selectedDocuments.has(doc.id)
const statusDisplay = getStatusDisplay(doc) const statusDisplay = getStatusDisplay(doc)
// const processingTime = getProcessingTime(doc) // const processingTime = getProcessingTime(doc)
@@ -1254,7 +1129,7 @@ export function KnowledgeBase({
</p> </p>
</div> </div>
<button <button
onClick={() => setUploadError(null)} onClick={() => clearError()}
className='flex-shrink-0 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring' className='flex-shrink-0 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring'
> >
<X className='h-4 w-4' /> <X className='h-4 w-4' />

View File

@@ -13,8 +13,8 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components/icons/document-icons' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components/icons/document-icons'
import type { DocumentData, KnowledgeBaseData } from '@/stores/knowledge/store' import type { KnowledgeBaseData } from '@/stores/knowledge/store'
import { useKnowledgeStore } from '@/stores/knowledge/store' import { useKnowledgeUpload } from '../../hooks/use-knowledge-upload'
const logger = createLogger('CreateModal') const logger = createLogger('CreateModal')
@@ -29,12 +29,6 @@ const ACCEPTED_FILE_TYPES = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
] ]
interface ProcessedDocumentResponse {
documentId: string
filename: string
status: string
}
interface FileWithPreview extends File { interface FileWithPreview extends File {
preview: string preview: string
} }
@@ -89,6 +83,12 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
const scrollContainerRef = useRef<HTMLDivElement>(null) const scrollContainerRef = useRef<HTMLDivElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null) const dropZoneRef = useRef<HTMLDivElement>(null)
const { uploadFiles } = useKnowledgeUpload({
onUploadComplete: (uploadedFiles) => {
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
},
})
// Cleanup file preview URLs when component unmounts to prevent memory leaks // Cleanup file preview URLs when component unmounts to prevent memory leaks
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -235,19 +235,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}` return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
} }
// Helper function to create uploadedFiles array from file uploads
const createUploadedFile = (
filename: string,
fileUrl: string,
fileSize: number,
mimeType: string
) => ({
filename,
fileUrl: fileUrl.startsWith('http') ? fileUrl : `${window.location.origin}${fileUrl}`,
fileSize,
mimeType,
})
const onSubmit = async (data: FormValues) => { const onSubmit = async (data: FormValues) => {
setIsSubmitting(true) setIsSubmitting(true)
setSubmitStatus(null) setSubmitStatus(null)
@@ -285,138 +272,14 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
const newKnowledgeBase = result.data const newKnowledgeBase = result.data
// If files are uploaded, upload them and start processing
if (files.length > 0) { if (files.length > 0) {
// First, upload all files to get their URLs const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
interface UploadedFile { chunkSize: data.maxChunkSize,
filename: string minCharactersPerChunk: data.minChunkSize,
fileUrl: string chunkOverlap: data.overlapSize,
fileSize: number recipe: 'default',
mimeType: string
}
const uploadedFiles: UploadedFile[] = []
for (const file of files) {
try {
const presignedResponse = await fetch('/api/files/presigned', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
}),
})
const presignedData = await presignedResponse.json()
if (presignedResponse.ok && presignedData.directUploadSupported) {
const uploadHeaders: Record<string, string> = {
'Content-Type': file.type,
}
// Add Azure-specific headers if provided
if (presignedData.uploadHeaders) {
Object.assign(uploadHeaders, presignedData.uploadHeaders)
}
const uploadResponse = await fetch(presignedData.presignedUrl, {
method: 'PUT',
headers: uploadHeaders, // Use the merged headers
body: file,
})
if (!uploadResponse.ok) {
throw new Error(
`Direct upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`
)
}
uploadedFiles.push(
createUploadedFile(file.name, presignedData.fileInfo.path, file.size, file.type)
)
} else {
const formData = new FormData()
formData.append('file', file)
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
})
if (!uploadResponse.ok) {
const errorData = await uploadResponse.json()
throw new Error(
`Failed to upload ${file.name}: ${errorData.error || 'Unknown error'}`
)
}
const uploadResult = await uploadResponse.json()
uploadedFiles.push(
createUploadedFile(file.name, uploadResult.path, file.size, file.type)
)
}
} catch (error) {
throw new Error(
`Failed to upload ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
// Start async document processing
const processResponse = await fetch(`/api/knowledge/${newKnowledgeBase.id}/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
documents: uploadedFiles,
processingOptions: {
chunkSize: data.maxChunkSize,
minCharactersPerChunk: data.minChunkSize,
chunkOverlap: data.overlapSize,
recipe: 'default',
lang: 'en',
},
bulk: true,
}),
}) })
if (!processResponse.ok) {
throw new Error('Failed to start document processing')
}
const processResult = await processResponse.json()
// Create pending document objects and add them to the store immediately
if (processResult.success && processResult.data.documentsCreated) {
const pendingDocuments: DocumentData[] = processResult.data.documentsCreated.map(
(doc: ProcessedDocumentResponse, index: number) => ({
id: doc.documentId,
knowledgeBaseId: newKnowledgeBase.id,
filename: doc.filename,
fileUrl: uploadedFiles[index].fileUrl,
fileSize: uploadedFiles[index].fileSize,
mimeType: uploadedFiles[index].mimeType,
chunkCount: 0,
tokenCount: 0,
characterCount: 0,
processingStatus: 'pending' as const,
processingStartedAt: null,
processingCompletedAt: null,
processingError: null,
enabled: true,
uploadedAt: new Date().toISOString(),
})
)
// Add pending documents to store for immediate UI update
useKnowledgeStore.getState().addPendingDocuments(newKnowledgeBase.id, pendingDocuments)
}
// Update the knowledge base object with the correct document count // Update the knowledge base object with the correct document count
newKnowledgeBase.docCount = uploadedFiles.length newKnowledgeBase.docCount = uploadedFiles.length

View File

@@ -0,0 +1,352 @@
import { useState } from 'react'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('KnowledgeUpload')
export interface UploadedFile {
filename: string
fileUrl: string
fileSize: number
mimeType: string
}
export interface UploadProgress {
stage: 'idle' | 'uploading' | 'processing' | 'completing'
filesCompleted: number
totalFiles: number
currentFile?: string
}
export interface UploadError {
message: string
timestamp: number
code?: string
details?: any
}
export interface ProcessingOptions {
chunkSize?: number
minCharactersPerChunk?: number
chunkOverlap?: number
recipe?: string
}
export interface UseKnowledgeUploadOptions {
onUploadComplete?: (uploadedFiles: UploadedFile[]) => void
onError?: (error: UploadError) => void
}
class KnowledgeUploadError extends Error {
constructor(
message: string,
public code: string,
public details?: any
) {
super(message)
this.name = 'KnowledgeUploadError'
}
}
class PresignedUrlError extends KnowledgeUploadError {
constructor(message: string, details?: any) {
super(message, 'PRESIGNED_URL_ERROR', details)
}
}
class DirectUploadError extends KnowledgeUploadError {
constructor(message: string, details?: any) {
super(message, 'DIRECT_UPLOAD_ERROR', details)
}
}
class ProcessingError extends KnowledgeUploadError {
constructor(message: string, details?: any) {
super(message, 'PROCESSING_ERROR', details)
}
}
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
stage: 'idle',
filesCompleted: 0,
totalFiles: 0,
})
const [uploadError, setUploadError] = useState<UploadError | null>(null)
const createUploadedFile = (
filename: string,
fileUrl: string,
fileSize: number,
mimeType: string
): UploadedFile => ({
filename,
fileUrl,
fileSize,
mimeType,
})
const createErrorFromException = (error: unknown, defaultMessage: string): UploadError => {
if (error instanceof KnowledgeUploadError) {
return {
message: error.message,
code: error.code,
details: error.details,
timestamp: Date.now(),
}
}
if (error instanceof Error) {
return {
message: error.message,
timestamp: Date.now(),
}
}
return {
message: defaultMessage,
timestamp: Date.now(),
}
}
const uploadFiles = async (
files: File[],
knowledgeBaseId: string,
processingOptions: ProcessingOptions = {}
): Promise<UploadedFile[]> => {
if (files.length === 0) {
throw new KnowledgeUploadError('No files provided for upload', 'NO_FILES')
}
if (!knowledgeBaseId?.trim()) {
throw new KnowledgeUploadError('Knowledge base ID is required', 'INVALID_KB_ID')
}
try {
setIsUploading(true)
setUploadError(null)
setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length })
const uploadedFiles: UploadedFile[] = []
// Upload all files using presigned URLs
for (const [index, file] of files.entries()) {
setUploadProgress((prev) => ({
...prev,
currentFile: file.name,
filesCompleted: index,
}))
try {
// Get presigned URL
const presignedResponse = await fetch('/api/files/presigned?type=knowledge-base', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
}),
})
if (!presignedResponse.ok) {
let errorDetails: any = null
try {
errorDetails = await presignedResponse.json()
} catch {
// Ignore JSON parsing errors
}
throw new PresignedUrlError(
`Failed to get presigned URL for ${file.name}: ${presignedResponse.status} ${presignedResponse.statusText}`,
errorDetails
)
}
const presignedData = await presignedResponse.json()
if (presignedData.directUploadSupported) {
// Use presigned URL for direct upload
const uploadHeaders: Record<string, string> = {
'Content-Type': file.type,
}
// Add Azure-specific headers if provided
if (presignedData.uploadHeaders) {
Object.assign(uploadHeaders, presignedData.uploadHeaders)
}
const uploadResponse = await fetch(presignedData.presignedUrl, {
method: 'PUT',
headers: uploadHeaders,
body: file,
})
if (!uploadResponse.ok) {
throw new DirectUploadError(
`Direct upload failed for ${file.name}: ${uploadResponse.status} ${uploadResponse.statusText}`,
{ uploadResponse: uploadResponse.statusText }
)
}
// Convert relative path to full URL for schema validation
const fullFileUrl = presignedData.fileInfo.path.startsWith('http')
? presignedData.fileInfo.path
: `${window.location.origin}${presignedData.fileInfo.path}`
uploadedFiles.push(createUploadedFile(file.name, fullFileUrl, file.size, file.type))
} else {
// Fallback to traditional upload through API route
const formData = new FormData()
formData.append('file', file)
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
})
if (!uploadResponse.ok) {
let errorData: any = null
try {
errorData = await uploadResponse.json()
} catch {
// Ignore JSON parsing errors
}
throw new DirectUploadError(
`Failed to upload ${file.name}: ${errorData?.error || 'Unknown error'}`,
errorData
)
}
const uploadResult = await uploadResponse.json()
// Validate upload result structure
if (!uploadResult.path) {
throw new DirectUploadError(
`Invalid upload response for ${file.name}: missing file path`,
uploadResult
)
}
uploadedFiles.push(
createUploadedFile(
file.name,
uploadResult.path.startsWith('http')
? uploadResult.path
: `${window.location.origin}${uploadResult.path}`,
file.size,
file.type
)
)
}
} catch (fileError) {
logger.error(`Error uploading file ${file.name}:`, fileError)
throw fileError // Re-throw to be caught by outer try-catch
}
}
setUploadProgress((prev) => ({ ...prev, stage: 'processing' }))
// Start async document processing
const processPayload = {
documents: uploadedFiles,
processingOptions: {
chunkSize: processingOptions.chunkSize || 1024,
minCharactersPerChunk: processingOptions.minCharactersPerChunk || 100,
chunkOverlap: processingOptions.chunkOverlap || 200,
recipe: processingOptions.recipe || 'default',
lang: 'en',
},
bulk: true,
}
const processResponse = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(processPayload),
})
if (!processResponse.ok) {
let errorData: any = null
try {
errorData = await processResponse.json()
} catch {
// Ignore JSON parsing errors
}
logger.error('Document processing failed:', {
status: processResponse.status,
error: errorData,
uploadedFiles: uploadedFiles.map((f) => ({
filename: f.filename,
fileUrl: f.fileUrl,
fileSize: f.fileSize,
mimeType: f.mimeType,
})),
})
throw new ProcessingError(
`Failed to start document processing: ${errorData?.error || errorData?.message || 'Unknown error'}`,
errorData
)
}
const processResult = await processResponse.json()
// Validate process result structure
if (!processResult.success) {
throw new ProcessingError(
`Document processing failed: ${processResult.error || 'Unknown error'}`,
processResult
)
}
if (!processResult.data || !processResult.data.documentsCreated) {
throw new ProcessingError(
'Invalid processing response: missing document data',
processResult
)
}
setUploadProgress((prev) => ({ ...prev, stage: 'completing' }))
logger.info(`Successfully started processing ${uploadedFiles.length} documents`)
// Call success callback
options.onUploadComplete?.(uploadedFiles)
return uploadedFiles
} catch (err) {
logger.error('Error uploading documents:', err)
const error = createErrorFromException(err, 'Unknown error occurred during upload')
setUploadError(error)
options.onError?.(error)
// Show user-friendly error message in console for debugging
console.error('Document upload failed:', error.message)
throw err
} finally {
setIsUploading(false)
setUploadProgress({ stage: 'idle', filesCompleted: 0, totalFiles: 0 })
}
}
const clearError = () => {
setUploadError(null)
}
return {
isUploading,
uploadProgress,
uploadError,
uploadFiles,
clearError,
}
}

View File

@@ -36,7 +36,7 @@ export function ControlBar() {
const fetchLogs = async () => { const fetchLogs = async () => {
try { try {
const queryParams = buildQueryParams(1, 50) // Get first 50 logs for refresh const queryParams = buildQueryParams(1, 50) // Get first 50 logs for refresh
const response = await fetch(`/api/logs?${queryParams}`) const response = await fetch(`/api/logs/enhanced?${queryParams}`)
if (!response.ok) { if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`) throw new Error(`Error fetching logs: ${response.statusText}`)

View File

@@ -0,0 +1,99 @@
'use client'
import { useState } from 'react'
import { Eye, Maximize2, Minimize2, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { FrozenCanvas } from './frozen-canvas'
interface FrozenCanvasModalProps {
executionId: string
workflowName?: string
trigger?: string
traceSpans?: any[] // TraceSpans data from log metadata
isOpen: boolean
onClose: () => void
}
export function FrozenCanvasModal({
executionId,
workflowName,
trigger,
traceSpans,
isOpen,
onClose,
}: FrozenCanvasModalProps) {
const [isFullscreen, setIsFullscreen] = useState(false)
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen)
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className={cn(
'flex flex-col gap-0 p-0',
isFullscreen
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] rounded-none'
: 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1100px]'
)}
hideCloseButton={true}
>
{/* Header */}
<DialogHeader className='flex flex-row items-center justify-between border-b bg-background p-4'>
<div className='flex items-center gap-3'>
<Eye className='h-5 w-5 text-blue-500 dark:text-blue-400' />
<div>
<DialogTitle className='font-semibold text-foreground text-lg'>
Logged Workflow State
</DialogTitle>
<div className='mt-1 flex items-center gap-2'>
{workflowName && (
<span className='text-muted-foreground text-sm'>{workflowName}</span>
)}
{trigger && (
<Badge variant='secondary' className='text-xs'>
{trigger}
</Badge>
)}
<span className='font-mono text-muted-foreground text-xs'>
{executionId.slice(0, 8)}...
</span>
</div>
</div>
</div>
<div className='flex items-center gap-2'>
<Button variant='ghost' size='sm' onClick={toggleFullscreen} className='h-8 w-8 p-0'>
{isFullscreen ? <Minimize2 className='h-4 w-4' /> : <Maximize2 className='h-4 w-4' />}
</Button>
<Button variant='ghost' size='sm' onClick={onClose} className='h-8 w-8 p-0'>
<X className='h-4 w-4' />
</Button>
</div>
</DialogHeader>
{/* Canvas Container */}
<div className='min-h-0 flex-1'>
<FrozenCanvas
executionId={executionId}
traceSpans={traceSpans}
height='100%'
width='100%'
/>
</div>
{/* Footer with instructions */}
<div className='border-t bg-background px-6 py-3'>
<div className='text-muted-foreground text-sm'>
💡 Click on blocks to see their input and output data at execution time. This canvas
shows the exact state of the workflow when this execution was captured.
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,467 @@
'use client'
import { useEffect, useState } from 'react'
import {
AlertCircle,
ChevronLeft,
ChevronRight,
Clock,
DollarSign,
Hash,
Loader2,
X,
Zap,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { createLogger } from '@/lib/logs/console-logger'
import { cn, redactApiKeys } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('FrozenCanvas')
function formatExecutionData(executionData: any) {
const {
inputData,
outputData,
cost,
tokens,
durationMs,
status,
blockName,
blockType,
errorMessage,
errorStackTrace,
} = executionData
return {
blockName: blockName || 'Unknown Block',
blockType: blockType || 'unknown',
status,
duration: durationMs ? `${durationMs}ms` : 'N/A',
input: redactApiKeys(inputData || {}),
output: redactApiKeys(outputData || {}),
errorMessage,
errorStackTrace,
cost: cost
? {
input: cost.input || 0,
output: cost.output || 0,
total: cost.total || 0,
}
: null,
tokens: tokens
? {
prompt: tokens.prompt || 0,
completion: tokens.completion || 0,
total: tokens.total || 0,
}
: null,
}
}
function getCurrentIterationData(blockExecutionData: any) {
if (blockExecutionData.iterations && Array.isArray(blockExecutionData.iterations)) {
const currentIndex = blockExecutionData.currentIteration ?? 0
return {
executionData: blockExecutionData.iterations[currentIndex],
currentIteration: currentIndex,
totalIterations: blockExecutionData.totalIterations ?? blockExecutionData.iterations.length,
hasMultipleIterations: blockExecutionData.iterations.length > 1,
}
}
return {
executionData: blockExecutionData,
currentIteration: 0,
totalIterations: 1,
hasMultipleIterations: false,
}
}
function PinnedLogs({ executionData, onClose }: { executionData: any; onClose: () => void }) {
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
const iterationInfo = getCurrentIterationData({
...executionData,
currentIteration: currentIterationIndex,
})
const formatted = formatExecutionData(iterationInfo.executionData)
const totalIterations = executionData.iterations?.length || 1
const goToPreviousIteration = () => {
if (currentIterationIndex > 0) {
setCurrentIterationIndex(currentIterationIndex - 1)
}
}
const goToNextIteration = () => {
if (currentIterationIndex < totalIterations - 1) {
setCurrentIterationIndex(currentIterationIndex + 1)
}
}
useEffect(() => {
setCurrentIterationIndex(0)
}, [executionData])
return (
<Card className='fixed top-4 right-4 z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border-border bg-background shadow-lg'>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-foreground text-lg'>
<Zap className='h-5 w-5' />
{formatted.blockName}
</CardTitle>
<button onClick={onClose} className='rounded-sm p-1 text-foreground hover:bg-muted'>
<X className='h-4 w-4' />
</button>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Badge variant={formatted.status === 'success' ? 'default' : 'destructive'}>
{formatted.blockType}
</Badge>
<Badge variant='outline'>{formatted.status}</Badge>
</div>
{/* Iteration Navigation */}
{iterationInfo.hasMultipleIterations && (
<div className='flex items-center gap-1'>
<button
onClick={goToPreviousIteration}
disabled={currentIterationIndex === 0}
className='rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
>
<ChevronLeft className='h-4 w-4' />
</button>
<span className='px-2 text-muted-foreground text-xs'>
{currentIterationIndex + 1} / {iterationInfo.totalIterations}
</span>
<button
onClick={goToNextIteration}
disabled={currentIterationIndex === totalIterations - 1}
className='rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
>
<ChevronRight className='h-4 w-4' />
</button>
</div>
)}
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='flex items-center gap-2'>
<Clock className='h-4 w-4 text-muted-foreground' />
<span className='text-foreground text-sm'>{formatted.duration}</span>
</div>
{formatted.cost && (
<div className='flex items-center gap-2'>
<DollarSign className='h-4 w-4 text-muted-foreground' />
<span className='text-foreground text-sm'>${formatted.cost.total.toFixed(5)}</span>
</div>
)}
{formatted.tokens && (
<div className='flex items-center gap-2'>
<Hash className='h-4 w-4 text-muted-foreground' />
<span className='text-foreground text-sm'>{formatted.tokens.total} tokens</span>
</div>
)}
</div>
<div>
<h4 className='mb-2 font-medium text-foreground text-sm'>Input</h4>
<div className='max-h-32 overflow-y-auto rounded bg-muted p-3 font-mono text-xs'>
<pre className='text-foreground'>{JSON.stringify(formatted.input, null, 2)}</pre>
</div>
</div>
<div>
<h4 className='mb-2 font-medium text-foreground text-sm'>Output</h4>
<div className='max-h-32 overflow-y-auto rounded bg-muted p-3 font-mono text-xs'>
<pre className='text-foreground'>{JSON.stringify(formatted.output, null, 2)}</pre>
</div>
</div>
{formatted.cost && (
<div>
<h4 className='mb-2 font-medium text-foreground text-sm'>Cost Breakdown</h4>
<div className='space-y-1 text-sm'>
<div className='flex justify-between text-foreground'>
<span>Input:</span>
<span>${formatted.cost.input.toFixed(5)}</span>
</div>
<div className='flex justify-between text-foreground'>
<span>Output:</span>
<span>${formatted.cost.output.toFixed(5)}</span>
</div>
<div className='flex justify-between border-border border-t pt-1 font-medium text-foreground'>
<span>Total:</span>
<span>${formatted.cost.total.toFixed(5)}</span>
</div>
</div>
</div>
)}
{formatted.tokens && (
<div>
<h4 className='mb-2 font-medium text-foreground text-sm'>Token Usage</h4>
<div className='space-y-1 text-sm'>
<div className='flex justify-between text-foreground'>
<span>Prompt:</span>
<span>{formatted.tokens.prompt}</span>
</div>
<div className='flex justify-between text-foreground'>
<span>Completion:</span>
<span>{formatted.tokens.completion}</span>
</div>
<div className='flex justify-between border-border border-t pt-1 font-medium text-foreground'>
<span>Total:</span>
<span>{formatted.tokens.total}</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)
}
interface FrozenCanvasData {
executionId: string
workflowId: string
workflowState: WorkflowState
executionMetadata: {
trigger: string
startedAt: string
endedAt?: string
totalDurationMs?: number
blockStats: {
total: number
success: number
error: number
skipped: number
}
cost: {
total: number | null
input: number | null
output: number | null
}
totalTokens: number | null
}
}
interface FrozenCanvasProps {
executionId: string
traceSpans?: any[]
className?: string
height?: string | number
width?: string | number
}
export function FrozenCanvas({
executionId,
traceSpans,
className,
height = '100%',
width = '100%',
}: FrozenCanvasProps) {
const [data, setData] = useState<FrozenCanvasData | null>(null)
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
// Process traceSpans to create blockExecutions map
useEffect(() => {
if (traceSpans && Array.isArray(traceSpans)) {
const blockExecutionMap: Record<string, any> = {}
const workflowSpan = traceSpans[0]
if (workflowSpan?.children && Array.isArray(workflowSpan.children)) {
const traceSpansByBlockId = workflowSpan.children.reduce((acc: any, span: any) => {
if (span.blockId) {
if (!acc[span.blockId]) {
acc[span.blockId] = []
}
acc[span.blockId].push(span)
}
return acc
}, {})
for (const [blockId, spans] of Object.entries(traceSpansByBlockId)) {
const spanArray = spans as any[]
const iterations = spanArray.map((span: any) => {
// Extract error information from span output if status is error
let errorMessage = null
let errorStackTrace = null
if (span.status === 'error' && span.output) {
// Error information can be in different formats in the output
if (typeof span.output === 'string') {
errorMessage = span.output
} else if (span.output.error) {
errorMessage = span.output.error
errorStackTrace = span.output.stackTrace || span.output.stack
} else if (span.output.message) {
errorMessage = span.output.message
errorStackTrace = span.output.stackTrace || span.output.stack
} else {
// Fallback: stringify the entire output for error cases
errorMessage = JSON.stringify(span.output)
}
}
return {
id: span.id,
blockId: span.blockId,
blockName: span.name,
blockType: span.type,
status: span.status,
startedAt: span.startTime,
endedAt: span.endTime,
durationMs: span.duration,
inputData: span.input,
outputData: span.output,
errorMessage,
errorStackTrace,
cost: span.cost || {
input: null,
output: null,
total: null,
},
tokens: span.tokens || {
prompt: null,
completion: null,
total: null,
},
modelUsed: span.model || null,
metadata: {},
}
})
blockExecutionMap[blockId] = {
iterations,
currentIteration: 0,
totalIterations: iterations.length,
}
}
}
setBlockExecutions(blockExecutionMap)
}
}, [traceSpans])
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch(`/api/logs/${executionId}/frozen-canvas`)
if (!response.ok) {
throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`)
}
const result = await response.json()
setData(result)
logger.debug(`Loaded frozen canvas data for execution: ${executionId}`)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
logger.error('Failed to fetch frozen canvas data:', err)
setError(errorMessage)
} finally {
setLoading(false)
}
}
fetchData()
}, [executionId])
// No need to create a temporary workflow - just use the workflowState directly
if (loading) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='flex items-center gap-2 text-muted-foreground'>
<Loader2 className='h-5 w-5 animate-spin' />
<span>Loading frozen canvas...</span>
</div>
</div>
)
}
if (error) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='flex items-center gap-2 text-destructive'>
<AlertCircle className='h-5 w-5' />
<span>Failed to load frozen canvas: {error}</span>
</div>
</div>
)
}
if (!data) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
<div className='text-muted-foreground'>No data available</div>
</div>
)
}
// Check if this is a migrated log without real workflow state
const isMigratedLog = (data.workflowState as any)?._migrated === true
if (isMigratedLog) {
return (
<div
className={cn('flex flex-col items-center justify-center gap-4 p-8', className)}
style={{ height, width }}
>
<div className='flex items-center gap-3 text-amber-600 dark:text-amber-400'>
<AlertCircle className='h-6 w-6' />
<span className='font-medium text-lg'>Logged State Not Found</span>
</div>
<div className='max-w-md text-center text-muted-foreground text-sm'>
This log was migrated from the old logging system. The workflow state at execution time is
not available.
</div>
<div className='text-muted-foreground text-xs'>
Note: {(data.workflowState as any)?._note}
</div>
</div>
)
}
return (
<>
<div style={{ height, width }} className={cn('frozen-canvas-mode h-full w-full', className)}>
<WorkflowPreview
workflowState={data.workflowState}
showSubBlocks={true}
isPannable={true}
onNodeClick={(blockId) => {
if (blockExecutions[blockId]) {
setPinnedBlockId(blockId)
}
}}
/>
</div>
{pinnedBlockId && blockExecutions[pinnedBlockId] && (
<PinnedLogs
executionData={blockExecutions[pinnedBlockId]}
onClose={() => setPinnedBlockId(null)}
/>
)}
</>
)
}

View File

@@ -0,0 +1,2 @@
export { FrozenCanvas } from './frozen-canvas'
export { FrozenCanvasModal } from './frozen-canvas-modal'

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, X } from 'lucide-react' import { ChevronDown, ChevronUp, Eye, X } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button' import { CopyButton } from '@/components/ui/copy-button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
@@ -10,6 +10,7 @@ import { redactApiKeys } from '@/lib/utils'
import type { WorkflowLog } from '@/app/workspace/[workspaceId]/logs/stores/types' import type { WorkflowLog } from '@/app/workspace/[workspaceId]/logs/stores/types'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
import { formatCost } from '@/providers/utils' import { formatCost } from '@/providers/utils'
import { FrozenCanvasModal } from '../frozen-canvas/frozen-canvas-modal'
import { ToolCallsDisplay } from '../tool-calls/tool-calls-display' import { ToolCallsDisplay } from '../tool-calls/tool-calls-display'
import { TraceSpansDisplay } from '../trace-spans/trace-spans-display' import { TraceSpansDisplay } from '../trace-spans/trace-spans-display'
import LogMarkdownRenderer from './components/markdown-renderer' import LogMarkdownRenderer from './components/markdown-renderer'
@@ -153,7 +154,7 @@ const BlockContentDisplay = ({
<> <>
<CopyButton text={redactedOutput} className='z-10 h-7 w-7' /> <CopyButton text={redactedOutput} className='z-10 h-7 w-7' />
{isJson ? ( {isJson ? (
<pre className='w-full overflow-visible whitespace-pre-wrap break-all text-sm'> <pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{redactedOutput} {redactedOutput}
</pre> </pre>
) : ( ) : (
@@ -166,7 +167,7 @@ const BlockContentDisplay = ({
text={JSON.stringify(redactedBlockInput, null, 2)} text={JSON.stringify(redactedBlockInput, null, 2)}
className='z-10 h-7 w-7' className='z-10 h-7 w-7'
/> />
<pre className='w-full overflow-visible whitespace-pre-wrap break-all text-sm'> <pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{JSON.stringify(redactedBlockInput, null, 2)} {JSON.stringify(redactedBlockInput, null, 2)}
</pre> </pre>
</> </>
@@ -193,6 +194,8 @@ export function Sidebar({
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [_currentLogId, setCurrentLogId] = useState<string | null>(null) const [_currentLogId, setCurrentLogId] = useState<string | null>(null)
const [isTraceExpanded, setIsTraceExpanded] = useState(false) const [isTraceExpanded, setIsTraceExpanded] = useState(false)
const [isModelsExpanded, setIsModelsExpanded] = useState(false)
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
// Update currentLogId when log changes // Update currentLogId when log changes
@@ -238,22 +241,26 @@ export function Sidebar({
// Determine if this is a workflow execution log // Determine if this is a workflow execution log
const isWorkflowExecutionLog = useMemo(() => { const isWorkflowExecutionLog = useMemo(() => {
if (!log) return false if (!log) return false
// Check if message contains "workflow executed" or similar phrases // Check if message contains workflow execution phrases (success or failure)
return ( return (
log.message.toLowerCase().includes('workflow executed') || log.message.toLowerCase().includes('workflow executed') ||
log.message.toLowerCase().includes('execution completed') || log.message.toLowerCase().includes('execution completed') ||
(log.trigger === 'manual' && log.duration) log.message.toLowerCase().includes('workflow execution failed') ||
log.message.toLowerCase().includes('execution failed') ||
(log.trigger === 'manual' && log.duration) ||
// Also check if we have enhanced logging metadata with trace spans
(log.metadata?.enhanced && log.metadata?.traceSpans)
) )
}, [log]) }, [log])
// Helper to determine if we have trace spans to display
const _hasTraceSpans = useMemo(() => {
return !!(log?.metadata?.traceSpans && log.metadata.traceSpans.length > 0)
}, [log])
// Helper to determine if we have cost information to display // Helper to determine if we have cost information to display
const hasCostInfo = useMemo(() => { const hasCostInfo = useMemo(() => {
return !!(log?.metadata?.cost && (log.metadata.cost.input || log.metadata.cost.output)) return !!(
log?.metadata?.cost &&
((log.metadata.cost.input && log.metadata.cost.input > 0) ||
(log.metadata.cost.output && log.metadata.cost.output > 0) ||
(log.metadata.cost.total && log.metadata.cost.total > 0))
)
}, [log]) }, [log])
const isWorkflowWithCost = useMemo(() => { const isWorkflowWithCost = useMemo(() => {
@@ -487,6 +494,103 @@ export function Sidebar({
</div> </div>
)} )}
{/* Enhanced Stats - only show for enhanced logs */}
{log.metadata?.enhanced && log.metadata?.blockStats && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>
Block Execution Stats
</h3>
<div className='space-y-1 text-sm'>
<div className='flex justify-between'>
<span>Total Blocks:</span>
<span className='font-medium'>{log.metadata.blockStats.total}</span>
</div>
<div className='flex justify-between'>
<span>Successful:</span>
<span className='font-medium text-green-600'>
{log.metadata.blockStats.success}
</span>
</div>
{log.metadata.blockStats.error > 0 && (
<div className='flex justify-between'>
<span>Failed:</span>
<span className='font-medium text-red-600'>
{log.metadata.blockStats.error}
</span>
</div>
)}
{log.metadata.blockStats.skipped > 0 && (
<div className='flex justify-between'>
<span>Skipped:</span>
<span className='font-medium text-yellow-600'>
{log.metadata.blockStats.skipped}
</span>
</div>
)}
</div>
</div>
)}
{/* Enhanced Cost - only show for enhanced logs with actual cost data */}
{log.metadata?.enhanced && hasCostInfo && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Cost Breakdown</h3>
<div className='space-y-1 text-sm'>
{(log.metadata?.cost?.total ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Total Cost:</span>
<span className='font-medium'>
${log.metadata?.cost?.total?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.input ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Input Cost:</span>
<span className='text-muted-foreground'>
${log.metadata?.cost?.input?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.output ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Output Cost:</span>
<span className='text-muted-foreground'>
${log.metadata?.cost?.output?.toFixed(4)}
</span>
</div>
)}
{(log.metadata?.cost?.tokens?.total ?? 0) > 0 && (
<div className='flex justify-between'>
<span>Total Tokens:</span>
<span className='text-muted-foreground'>
{log.metadata?.cost?.tokens?.total?.toLocaleString()}
</span>
</div>
)}
</div>
</div>
)}
{/* Frozen Canvas Button - only show for workflow execution logs with execution ID */}
{isWorkflowExecutionLog && log.executionId && (
<div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Workflow State</h3>
<Button
variant='outline'
size='sm'
onClick={() => setIsFrozenCanvasOpen(true)}
className='w-full justify-start gap-2'
>
<Eye className='h-4 w-4' />
View Frozen Canvas
</Button>
<p className='mt-1 text-muted-foreground text-xs'>
See the exact workflow state and block inputs/outputs at execution time
</p>
</div>
)}
{/* Message Content */} {/* Message Content */}
<div className='w-full pb-2'> <div className='w-full pb-2'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Message</h3> <h3 className='mb-1 font-medium text-muted-foreground text-xs'>Message</h3>
@@ -517,42 +621,94 @@ export function Sidebar({
)} )}
{/* Cost Information (moved to bottom) */} {/* Cost Information (moved to bottom) */}
{hasCostInfo && log.metadata?.cost && ( {hasCostInfo && (
<div> <div>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'> <h3 className='mb-1 font-medium text-muted-foreground text-xs'>Models</h3>
{isWorkflowWithCost ? 'Total Model Cost' : 'Model Cost'}
</h3>
<div className='overflow-hidden rounded-md border'> <div className='overflow-hidden rounded-md border'>
<div className='space-y-2 p-3'> <div className='space-y-2 p-3'>
{log.metadata.cost.model && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Model:</span>
<span className='text-sm'>{log.metadata.cost.model}</span>
</div>
)}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Input:</span> <span className='text-muted-foreground text-sm'>Input:</span>
<span className='text-sm'>{formatCost(log.metadata.cost.input || 0)}</span> <span className='text-sm'>
{formatCost(log.metadata?.cost?.input || 0)}
</span>
</div> </div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Output:</span> <span className='text-muted-foreground text-sm'>Output:</span>
<span className='text-sm'>{formatCost(log.metadata.cost.output || 0)}</span> <span className='text-sm'>
{formatCost(log.metadata?.cost?.output || 0)}
</span>
</div> </div>
<div className='mt-1 flex items-center justify-between border-t pt-2'> <div className='mt-1 flex items-center justify-between border-t pt-2'>
<span className='text-muted-foreground text-sm'>Total:</span> <span className='text-muted-foreground text-sm'>Total:</span>
<span className='text-foreground text-sm'> <span className='text-foreground text-sm'>
{formatCost(log.metadata.cost.total || 0)} {formatCost(log.metadata?.cost?.total || 0)}
</span> </span>
</div> </div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs'>Tokens:</span> <span className='text-muted-foreground text-xs'>Tokens:</span>
<span className='text-muted-foreground text-xs'> <span className='text-muted-foreground text-xs'>
{log.metadata.cost.tokens?.prompt || 0} in /{' '} {log.metadata?.cost?.tokens?.prompt || 0} in /{' '}
{log.metadata.cost.tokens?.completion || 0} out {log.metadata?.cost?.tokens?.completion || 0} out
</span> </span>
</div> </div>
</div> </div>
{/* Models Breakdown */}
{log.metadata?.cost?.models &&
Object.keys(log.metadata?.cost?.models).length > 0 && (
<div className='border-t'>
<button
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
>
<span className='font-medium text-muted-foreground text-xs'>
Model Breakdown (
{Object.keys(log.metadata?.cost?.models || {}).length})
</span>
{isModelsExpanded ? (
<ChevronUp className='h-3 w-3 text-muted-foreground' />
) : (
<ChevronDown className='h-3 w-3 text-muted-foreground' />
)}
</button>
{isModelsExpanded && (
<div className='space-y-3 border-t bg-muted/30 p-3'>
{Object.entries(log.metadata?.cost?.models || {}).map(
([model, cost]: [string, any]) => (
<div key={model} className='space-y-1'>
<div className='font-medium font-mono text-xs'>{model}</div>
<div className='space-y-1 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Input:</span>
<span>{formatCost(cost.input || 0)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Output:</span>
<span>{formatCost(cost.output || 0)}</span>
</div>
<div className='flex justify-between border-t pt-1'>
<span className='text-muted-foreground'>Total:</span>
<span className='font-medium'>
{formatCost(cost.total || 0)}
</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Tokens:</span>
<span>
{cost.tokens?.prompt || 0} in /{' '}
{cost.tokens?.completion || 0} out
</span>
</div>
</div>
</div>
)
)}
</div>
)}
</div>
)}
{isWorkflowWithCost && ( {isWorkflowWithCost && (
<div className='border-t bg-muted p-3 text-muted-foreground text-xs'> <div className='border-t bg-muted p-3 text-muted-foreground text-xs'>
<p> <p>
@@ -568,6 +724,18 @@ export function Sidebar({
</ScrollArea> </ScrollArea>
</> </>
)} )}
{/* Frozen Canvas Modal */}
{log?.executionId && (
<FrozenCanvasModal
executionId={log.executionId}
workflowName={log.workflow?.name}
trigger={log.trigger || undefined}
traceSpans={log.metadata?.traceSpans}
isOpen={isFrozenCanvasOpen}
onClose={() => setIsFrozenCanvasOpen(false)}
/>
)}
</div> </div>
) )
} }

View File

@@ -111,7 +111,7 @@ function ToolCallItem({ toolCall, index }: ToolCallItemProps) {
{toolCall.input && ( {toolCall.input && (
<div> <div>
<div className='mb-1 text-muted-foreground'>Input</div> <div className='mb-1 text-muted-foreground'>Input</div>
<pre className='group relative max-h-32 overflow-auto rounded bg-background p-2'> <pre className='group relative max-h-32 overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all rounded bg-background p-2'>
<CopyButton text={JSON.stringify(toolCall.input, null, 2)} /> <CopyButton text={JSON.stringify(toolCall.input, null, 2)} />
<code>{JSON.stringify(toolCall.input, null, 2)}</code> <code>{JSON.stringify(toolCall.input, null, 2)}</code>
</pre> </pre>
@@ -122,7 +122,7 @@ function ToolCallItem({ toolCall, index }: ToolCallItemProps) {
{toolCall.status === 'success' && toolCall.output && ( {toolCall.status === 'success' && toolCall.output && (
<div> <div>
<div className='mb-1 text-muted-foreground'>Output</div> <div className='mb-1 text-muted-foreground'>Output</div>
<pre className='group relative max-h-32 overflow-auto rounded bg-background p-2'> <pre className='group relative max-h-32 overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all rounded bg-background p-2'>
<CopyButton text={JSON.stringify(toolCall.output, null, 2)} /> <CopyButton text={JSON.stringify(toolCall.output, null, 2)} />
<code>{JSON.stringify(toolCall.output, null, 2)}</code> <code>{JSON.stringify(toolCall.output, null, 2)}</code>
</pre> </pre>
@@ -132,7 +132,7 @@ function ToolCallItem({ toolCall, index }: ToolCallItemProps) {
{toolCall.status === 'error' && toolCall.error && ( {toolCall.status === 'error' && toolCall.error && (
<div> <div>
<div className='mb-1 text-destructive'>Error</div> <div className='mb-1 text-destructive'>Error</div>
<pre className='group relative max-h-32 overflow-auto rounded bg-destructive/10 p-2 text-destructive'> <pre className='group relative max-h-32 overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all rounded bg-destructive/10 p-2 text-destructive'>
<CopyButton text={toolCall.error} /> <CopyButton text={toolCall.error} />
<code>{toolCall.error}</code> <code>{toolCall.error}</code>
</pre> </pre>

View File

@@ -27,6 +27,174 @@ interface TraceSpansDisplayProps {
onExpansionChange?: (expanded: boolean) => void onExpansionChange?: (expanded: boolean) => void
} }
// Transform raw block data into clean, user-friendly format
function transformBlockData(data: any, blockType: string, isInput: boolean) {
if (!data) return null
// For input data, filter out sensitive information
if (isInput) {
const cleanInput = { ...data }
// Remove sensitive fields
if (cleanInput.apiKey) {
cleanInput.apiKey = '***'
}
if (cleanInput.azureApiKey) {
cleanInput.azureApiKey = '***'
}
// Remove null/undefined values for cleaner display
Object.keys(cleanInput).forEach((key) => {
if (cleanInput[key] === null || cleanInput[key] === undefined) {
delete cleanInput[key]
}
})
return cleanInput
}
// For output data, extract meaningful information based on block type
if (data.response) {
const response = data.response
switch (blockType) {
case 'agent':
return {
content: response.content,
model: data.model,
tokens: data.tokens,
toolCalls: response.toolCalls,
...(data.cost && { cost: data.cost }),
}
case 'function':
return {
result: response.result,
stdout: response.stdout,
...(response.executionTime && { executionTime: `${response.executionTime}ms` }),
}
case 'api':
return {
data: response.data,
status: response.status,
headers: response.headers,
}
default:
// For other block types, show the response content
return response
}
}
return data
}
// Component to display block input/output data in a clean, readable format
function BlockDataDisplay({
data,
blockType,
isInput = false,
isError = false,
}: {
data: any
blockType?: string
isInput?: boolean
isError?: boolean
}) {
if (!data) return null
// Handle different data types
const renderValue = (value: any, key?: string): React.ReactNode => {
if (value === null) return <span className='text-muted-foreground italic'>null</span>
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
if (typeof value === 'string') {
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
}
if (typeof value === 'number') {
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
}
if (typeof value === 'boolean') {
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
return (
<div className='space-y-1'>
<span className='text-muted-foreground'>[</span>
<div className='ml-4 space-y-1'>
{value.map((item, index) => (
<div key={index} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
</div>
))}
</div>
<span className='text-muted-foreground'>]</span>
</div>
)
}
if (typeof value === 'object') {
const entries = Object.entries(value)
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
return (
<div className='space-y-1'>
{entries.map(([objKey, objValue]) => (
<div key={objKey} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
{objKey}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
</div>
))}
</div>
)
}
return <span>{String(value)}</span>
}
// Transform the data for better display
const transformedData = transformBlockData(data, blockType || 'unknown', isInput)
// Special handling for error output
if (isError && data.error) {
return (
<div className='space-y-2 text-xs'>
<div className='rounded border border-red-200 bg-red-50 p-2 dark:border-red-800 dark:bg-red-950/20'>
<div className='mb-1 font-medium text-red-800 dark:text-red-400'>Error</div>
<div className='text-red-700 dark:text-red-300'>{data.error}</div>
</div>
{/* Show other output data if available */}
{transformedData &&
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
.length > 0 && (
<div className='space-y-1'>
{Object.entries(transformedData)
.filter(([key]) => key !== 'error' && key !== 'success')
.map(([key, value]) => (
<div key={key} className='flex gap-2'>
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
{renderValue(value, key)}
</div>
))}
</div>
)}
</div>
)
}
return (
<div className='space-y-1 overflow-hidden text-xs'>{renderValue(transformedData || data)}</div>
)
}
export function TraceSpansDisplay({ export function TraceSpansDisplay({
traceSpans, traceSpans,
totalDuration = 0, totalDuration = 0,
@@ -35,6 +203,30 @@ export function TraceSpansDisplay({
// Keep track of expanded spans // Keep track of expanded spans
const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set()) const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set())
// Function to collect all span IDs recursively (for expand all functionality)
const collectAllSpanIds = (spans: TraceSpan[]): string[] => {
const ids: string[] = []
const collectIds = (span: TraceSpan) => {
const spanId = span.id || `span-${span.name}-${span.startTime}`
ids.push(spanId)
// Process children
if (span.children && span.children.length > 0) {
span.children.forEach(collectIds)
}
}
spans.forEach(collectIds)
return ids
}
const allSpanIds = useMemo(() => {
if (!traceSpans || traceSpans.length === 0) return []
return collectAllSpanIds(traceSpans)
}, [traceSpans])
// Early return after all hooks
if (!traceSpans || traceSpans.length === 0) { if (!traceSpans || traceSpans.length === 0) {
return <div className='text-muted-foreground text-sm'>No trace data available</div> return <div className='text-muted-foreground text-sm'>No trace data available</div>
} }
@@ -61,26 +253,6 @@ export function TraceSpansDisplay({
// This ensures parallel spans are represented correctly in the timeline // This ensures parallel spans are represented correctly in the timeline
const actualTotalDuration = workflowEndTime - workflowStartTime const actualTotalDuration = workflowEndTime - workflowStartTime
// Function to collect all span IDs recursively (for expand all functionality)
const collectAllSpanIds = (spans: TraceSpan[]): string[] => {
const ids: string[] = []
const collectIds = (span: TraceSpan) => {
const spanId = span.id || `span-${span.name}-${span.startTime}`
ids.push(spanId)
// Process children
if (span.children && span.children.length > 0) {
span.children.forEach(collectIds)
}
}
spans.forEach(collectIds)
return ids
}
const allSpanIds = useMemo(() => collectAllSpanIds(traceSpans), [traceSpans])
// Handle span toggling // Handle span toggling
const handleSpanToggle = (spanId: string, expanded: boolean, hasSubItems: boolean) => { const handleSpanToggle = (spanId: string, expanded: boolean, hasSubItems: boolean) => {
const newExpandedSpans = new Set(expandedSpans) const newExpandedSpans = new Set(expandedSpans)
@@ -140,11 +312,14 @@ export function TraceSpansDisplay({
)} )}
</button> </button>
</div> </div>
<div className='overflow-hidden rounded-md border shadow-sm'> <div className='w-full overflow-hidden rounded-md border shadow-sm'>
{traceSpans.map((span, index) => { {traceSpans.map((span, index) => {
const hasSubItems = const hasSubItems = Boolean(
(span.children && span.children.length > 0) || (span.children && span.children.length > 0) ||
(span.toolCalls && span.toolCalls.length > 0) (span.toolCalls && span.toolCalls.length > 0) ||
span.input ||
span.output
)
return ( return (
<TraceSpanItem <TraceSpanItem
key={index} key={index}
@@ -430,6 +605,43 @@ function TraceSpanItem({
</div> </div>
</div> </div>
{/* Children and tool calls */}
{expanded && (
<div>
{/* Block Input/Output Data */}
{(span.input || span.output) && (
<div className='mt-2 ml-8 space-y-3 overflow-hidden'>
{/* Input Data */}
{span.input && (
<div>
<h4 className='mb-2 font-medium text-muted-foreground text-xs'>Input</h4>
<div className='overflow-hidden rounded-md bg-secondary/30 p-3'>
<BlockDataDisplay data={span.input} blockType={span.type} isInput={true} />
</div>
</div>
)}
{/* Output Data */}
{span.output && (
<div>
<h4 className='mb-2 font-medium text-muted-foreground text-xs'>
{span.status === 'error' ? 'Error Details' : 'Output'}
</h4>
<div className='overflow-hidden rounded-md bg-secondary/30 p-3'>
<BlockDataDisplay
data={span.output}
blockType={span.type}
isInput={false}
isError={span.status === 'error'}
/>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Children and tool calls */} {/* Children and tool calls */}
{expanded && ( {expanded && (
<div> <div>
@@ -437,9 +649,12 @@ function TraceSpanItem({
{hasChildren && ( {hasChildren && (
<div> <div>
{span.children?.map((childSpan, index) => { {span.children?.map((childSpan, index) => {
const childHasSubItems = const childHasSubItems = Boolean(
(childSpan.children && childSpan.children.length > 0) || (childSpan.children && childSpan.children.length > 0) ||
(childSpan.toolCalls && childSpan.toolCalls.length > 0) (childSpan.toolCalls && childSpan.toolCalls.length > 0) ||
childSpan.input ||
childSpan.output
)
return ( return (
<TraceSpanItem <TraceSpanItem

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Info, Loader2 } from 'lucide-react' import { AlertCircle, Info, Loader2 } from 'lucide-react'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { useSidebarStore } from '@/stores/sidebar/store' import { useSidebarStore } from '@/stores/sidebar/store'
@@ -14,34 +14,6 @@ import { formatDate } from './utils/format-date'
const logger = createLogger('Logs') const logger = createLogger('Logs')
const LOGS_PER_PAGE = 50 const LOGS_PER_PAGE = 50
const getLevelBadgeStyles = (level: string) => {
switch (level.toLowerCase()) {
case 'error':
return 'bg-destructive/20 text-destructive error-badge'
case 'warn':
return 'bg-warning/20 text-warning'
default:
return 'bg-secondary text-secondary-foreground'
}
}
const getTriggerBadgeStyles = (trigger: string) => {
switch (trigger.toLowerCase()) {
case 'manual':
return 'bg-secondary text-secondary-foreground'
case 'api':
return 'bg-blue-100 dark:bg-blue-950/40 text-blue-700 dark:text-blue-400'
case 'webhook':
return 'bg-orange-100 dark:bg-orange-950/40 text-orange-700 dark:text-orange-400'
case 'schedule':
return 'bg-green-100 dark:bg-green-950/40 text-green-700 dark:text-green-400'
case 'chat':
return 'bg-purple-100 dark:bg-purple-950/40 text-purple-700 dark:text-purple-400'
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400'
}
}
const selectedRowAnimation = ` const selectedRowAnimation = `
@keyframes borderPulse { @keyframes borderPulse {
0% { border-left-color: hsl(var(--primary) / 0.3) } 0% { border-left-color: hsl(var(--primary) / 0.3) }
@@ -87,28 +59,6 @@ export default function Logs() {
const isSidebarCollapsed = const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
const executionGroups = useMemo(() => {
const groups: Record<string, WorkflowLog[]> = {}
// Group logs by executionId
logs.forEach((log) => {
if (log.executionId) {
if (!groups[log.executionId]) {
groups[log.executionId] = []
}
groups[log.executionId].push(log)
}
})
Object.keys(groups).forEach((executionId) => {
groups[executionId].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
})
return groups
}, [logs])
const handleLogClick = (log: WorkflowLog) => { const handleLogClick = (log: WorkflowLog) => {
setSelectedLog(log) setSelectedLog(log)
const index = logs.findIndex((l) => l.id === log.id) const index = logs.findIndex((l) => l.id === log.id)
@@ -134,6 +84,8 @@ export default function Logs() {
const handleCloseSidebar = () => { const handleCloseSidebar = () => {
setIsSidebarOpen(false) setIsSidebarOpen(false)
setSelectedLog(null)
setSelectedLogIndex(-1)
} }
useEffect(() => { useEffect(() => {
@@ -155,7 +107,7 @@ export default function Logs() {
} }
const queryParams = buildQueryParams(pageNum, LOGS_PER_PAGE) const queryParams = buildQueryParams(pageNum, LOGS_PER_PAGE)
const response = await fetch(`/api/logs?${queryParams}`) const response = await fetch(`/api/logs/enhanced?${queryParams}`)
if (!response.ok) { if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`) throw new Error(`Error fetching logs: ${response.statusText}`)
@@ -203,7 +155,7 @@ export default function Logs() {
try { try {
setLoading(true) setLoading(true)
const queryParams = buildQueryParams(1, LOGS_PER_PAGE) const queryParams = buildQueryParams(1, LOGS_PER_PAGE)
const response = await fetch(`/api/logs?${queryParams}`) const response = await fetch(`/api/logs/enhanced?${queryParams}`)
if (!response.ok) { if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`) throw new Error(`Error fetching logs: ${response.statusText}`)
@@ -353,46 +305,19 @@ export default function Logs() {
<div className='flex flex-1 flex-col overflow-hidden'> <div className='flex flex-1 flex-col overflow-hidden'>
{/* Table container */} {/* Table container */}
<div className='flex flex-1 flex-col overflow-hidden'> <div className='flex flex-1 flex-col overflow-hidden'>
{/* Table header - fixed */} {/* Table with fixed layout */}
<div className='sticky top-0 z-10 border-b bg-background'> <div className='w-full min-w-[800px]'>
<table className='w-full table-fixed'> {/* Header */}
<colgroup> <div className='border-border/50 border-b'>
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[19%]'}`} /> <div className='grid grid-cols-[160px_100px_1fr_120px_100px_100px] gap-4 px-4 py-3 font-medium text-muted-foreground text-xs'>
<col className='w-[8%] md:w-[7%]' /> <div>Time</div>
<col className='w-[12%] md:w-[10%]' /> <div>Status</div>
<col className='hidden w-[8%] lg:table-column' /> <div>Workflow</div>
<col className='hidden w-[8%] lg:table-column' /> <div className='hidden lg:block'>Trigger</div>
<col <div className='hidden xl:block'>Cost</div>
className={`${isSidebarCollapsed ? 'w-auto md:w-[53%] lg:w-auto' : 'w-auto md:w-[50%] lg:w-auto'}`} <div>Duration</div>
/> </div>
<col className='w-[8%] md:w-[10%]' /> </div>
</colgroup>
<thead>
<tr>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Time</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Workflow</span>
</th>
<th className='hidden px-4 pt-2 pb-3 text-left font-medium lg:table-cell'>
<span className='text-muted-foreground text-xs leading-none'>id</span>
</th>
<th className='hidden px-4 pt-2 pb-3 text-left font-medium lg:table-cell'>
<span className='text-muted-foreground text-xs leading-none'>Trigger</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Message</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Duration</span>
</th>
</tr>
</thead>
</table>
</div> </div>
{/* Table body - scrollable */} {/* Table body - scrollable */}
@@ -419,163 +344,106 @@ export default function Logs() {
</div> </div>
</div> </div>
) : ( ) : (
<table className='w-full table-fixed'> <div className='space-y-1 p-4'>
<colgroup> {logs.map((log) => {
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[19%]'}`} /> const formattedDate = formatDate(log.createdAt)
<col className='w-[8%] md:w-[7%]' /> const isSelected = selectedLog?.id === log.id
<col className='w-[12%] md:w-[10%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className='hidden w-[8%] lg:table-column' />
<col
className={`${isSidebarCollapsed ? 'w-auto md:w-[53%] lg:w-auto' : 'w-auto md:w-[50%] lg:w-auto'}`}
/>
<col className='w-[8%] md:w-[10%]' />
</colgroup>
<tbody>
{logs.map((log) => {
const formattedDate = formatDate(log.createdAt)
const isSelected = selectedLog?.id === log.id
const _isWorkflowExecutionLog =
log.executionId && executionGroups[log.executionId].length === 1
return ( return (
<tr <div
key={log.id} key={log.id}
ref={isSelected ? selectedRowRef : null} ref={isSelected ? selectedRowRef : null}
className={`cursor-pointer border-b transition-colors ${ className={`cursor-pointer rounded-lg border transition-all duration-200 ${
isSelected isSelected
? 'selected-row border-l-2 bg-accent/40 hover:bg-accent/50' ? 'border-primary bg-accent/40 shadow-sm'
: 'hover:bg-accent/30' : 'border-border hover:border-border/80 hover:bg-accent/20'
}`} }`}
onClick={() => handleLogClick(log)} onClick={() => handleLogClick(log)}
> >
{/* Time column */} <div className='grid grid-cols-[160px_100px_1fr_120px_100px_100px] gap-4 p-4'>
<td className='px-4 py-3'> {/* Time */}
<div className='flex flex-col justify-center'> <div>
<div className='flex items-center font-medium text-xs'> <div className='font-medium text-sm'>{formattedDate.formatted}</div>
<span>{formattedDate.formatted}</span> <div className='text-muted-foreground text-xs'>
<span className='mx-1.5 hidden text-muted-foreground xl:inline'> {formattedDate.relative}
</span>
<span className='hidden text-muted-foreground xl:inline'>
{new Date(log.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</div>
<div className='mt-0.5 text-muted-foreground text-xs'>
<span>{formattedDate.relative}</span>
</div>
</div> </div>
</td> </div>
{/* Level column */} {/* Status */}
<td className='px-4 py-3'> <div>
<div <div
className={`inline-flex items-center justify-center rounded-md px-2 py-1 text-xs ${getLevelBadgeStyles(log.level)}`} className={`inline-flex items-center justify-center rounded-md px-2 py-1 text-xs ${
log.level === 'error'
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'
}`}
> >
<span className='font-medium'>{log.level}</span> <span className='font-medium'>
{log.level === 'error' ? 'Failed' : 'Success'}
</span>
</div> </div>
</td> </div>
{/* Workflow column */} {/* Workflow */}
<td className='px-4 py-3'> <div className='min-w-0'>
{log.workflow && ( <div className='truncate font-medium text-sm'>
<div {log.workflow?.name || 'Unknown Workflow'}
className='inline-flex max-w-full items-center truncate rounded-md px-2 py-1 text-xs'
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
}}
title={log.workflow.name}
>
<span className='truncate font-medium'>{log.workflow.name}</span>
</div>
)}
</td>
{/* ID column - hidden on small screens */}
<td className='hidden px-4 py-3 lg:table-cell'>
<div className='font-mono text-muted-foreground text-xs'>
{log.executionId ? `#${log.executionId.substring(0, 4)}` : '—'}
</div> </div>
</td> <div className='truncate text-muted-foreground text-xs'>
{/* Trigger column - hidden on medium screens and below */}
<td className='hidden px-4 py-3 lg:table-cell'>
{log.trigger && (
<div
className={`inline-flex items-center rounded-md px-2 py-1 text-xs ${getTriggerBadgeStyles(log.trigger)}`}
>
<span className='font-medium'>{log.trigger}</span>
</div>
)}
</td>
{/* Message column */}
<td className='px-4 py-3'>
<div className='truncate text-sm' title={log.message}>
{log.message} {log.message}
</div> </div>
</td> </div>
{/* Duration column */} {/* Trigger */}
<td className='px-4 py-3'> <div className='hidden lg:block'>
<div className='text-muted-foreground text-xs'>
{log.trigger || '—'}
</div>
</div>
{/* Cost */}
<div className='hidden xl:block'>
<div className='text-xs'>
{log.metadata?.enhanced && log.metadata?.cost?.total ? (
<span className='text-muted-foreground'>
${log.metadata.cost.total.toFixed(4)}
</span>
) : (
<span className='text-muted-foreground'></span>
)}
</div>
</div>
{/* Duration */}
<div>
<div className='text-muted-foreground text-xs'> <div className='text-muted-foreground text-xs'>
{log.duration || '—'} {log.duration || '—'}
</div> </div>
</td>
</tr>
)
})}
{/* Infinite scroll loader */}
{hasMore && (
<tr>
<td colSpan={7}>
<div
ref={loaderRef}
className='flex items-center justify-center py-2'
style={{ height: '50px' }}
>
{isFetchingMore && (
<div className='flex items-center gap-2 text-muted-foreground opacity-70'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-xs'>Loading more logs...</span>
</div>
)}
</div>
</td>
</tr>
)}
{/* Footer status indicator - useful for development */}
<tr className='border-t'>
<td colSpan={7}>
<div className='flex items-center justify-between px-4 py-2 text-muted-foreground text-xs'>
<span>Showing {logs.length} logs</span>
<div className='flex items-center gap-4'>
{isFetchingMore ? (
<div className='flex items-center gap-2' />
) : hasMore ? (
<button
type='button'
onClick={loadMoreLogs}
className='text-primary text-xs hover:underline'
>
Load more logs
</button>
) : (
<span>End of logs</span>
)}
</div> </div>
</div> </div>
</td> </div>
</tr> )
</tbody> })}
</table>
{/* Infinite scroll loader */}
{hasMore && (
<div className='flex items-center justify-center py-4'>
<div
ref={loaderRef}
className='flex items-center gap-2 text-muted-foreground'
>
{isFetchingMore ? (
<>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-sm'>Loading more...</span>
</>
) : (
<span className='text-sm'>Scroll to load more</span>
)}
</div>
</div>
)}
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -22,7 +22,19 @@ export interface ToolCallMetadata {
} }
export interface CostMetadata { export interface CostMetadata {
model?: string models?: Record<
string,
{
input: number
output: number
total: number
tokens?: {
prompt?: number
completion?: number
total?: number
}
}
>
input?: number input?: number
output?: number output?: number
total?: number total?: number
@@ -53,6 +65,7 @@ export interface TraceSpan {
relativeStartMs?: number // Time in ms from the start of the parent span relativeStartMs?: number // Time in ms from the start of the parent span
blockId?: string // Added to track the original block ID for relationship mapping blockId?: string // Added to track the original block ID for relationship mapping
input?: Record<string, any> // Added to store input data for this span input?: Record<string, any> // Added to store input data for this span
output?: Record<string, any> // Added to store output data for this span
} }
export interface WorkflowLog { export interface WorkflowLog {
@@ -70,6 +83,29 @@ export interface WorkflowLog {
totalDuration?: number totalDuration?: number
cost?: CostMetadata cost?: CostMetadata
blockInput?: Record<string, any> blockInput?: Record<string, any>
enhanced?: boolean
blockStats?: {
total: number
success: number
error: number
skipped: number
}
blockExecutions?: Array<{
id: string
blockId: string
blockName: string
blockType: string
startedAt: string
endedAt: string
durationMs: number
status: 'success' | 'error' | 'skipped'
errorMessage?: string
errorStackTrace?: string
inputData: any
outputData: any
cost?: CostMetadata
metadata: any
}>
} }
} }

View File

@@ -30,6 +30,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { getBaseDomain } from '@/lib/urls/utils' import { getBaseDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -54,7 +55,7 @@ interface ChatDeployProps {
type AuthType = 'public' | 'password' | 'email' type AuthType = 'public' | 'password' | 'email'
const getDomainSuffix = (() => { const getDomainSuffix = (() => {
const suffix = process.env.NODE_ENV === 'development' ? `.${getBaseDomain()}` : '.simstudio.ai' const suffix = env.NODE_ENV === 'development' ? `.${getBaseDomain()}` : '.simstudio.ai'
return () => suffix return () => suffix
})() })()

View File

@@ -458,7 +458,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
* Handle deleting the current workflow * Handle deleting the current workflow
*/ */
const handleDeleteWorkflow = () => { const handleDeleteWorkflow = () => {
if (!activeWorkflowId || !userPermissions.canEdit) return if (!activeWorkflowId || !userPermissions.canAdmin) return
const sidebarWorkflows = getSidebarOrderedWorkflows() const sidebarWorkflows = getSidebarOrderedWorkflows()
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId) const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId)
@@ -691,12 +691,12 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
* Render delete workflow button with confirmation dialog * Render delete workflow button with confirmation dialog
*/ */
const renderDeleteButton = () => { const renderDeleteButton = () => {
const canEdit = userPermissions.canEdit const canAdmin = userPermissions.canAdmin
const hasMultipleWorkflows = Object.keys(workflows).length > 1 const hasMultipleWorkflows = Object.keys(workflows).length > 1
const isDisabled = !canEdit || !hasMultipleWorkflows const isDisabled = !canAdmin || !hasMultipleWorkflows
const getTooltipText = () => { const getTooltipText = () => {
if (!canEdit) return 'Admin permission required to delete workflows' if (!canAdmin) return 'Admin permission required to delete workflows'
if (!hasMultipleWorkflows) return 'Cannot delete the last workflow' if (!hasMultipleWorkflows) return 'Cannot delete the last workflow'
return 'Delete Workflow' return 'Delete Workflow'
} }

View File

@@ -140,12 +140,20 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
result.logs?.filter((log) => !messageIdMap.has(log.blockId)) || [] result.logs?.filter((log) => !messageIdMap.has(log.blockId)) || []
if (nonStreamingLogs.length > 0) { if (nonStreamingLogs.length > 0) {
const outputsToRender = selectedOutputs.filter((outputId) => const outputsToRender = selectedOutputs.filter((outputId) => {
nonStreamingLogs.some((log) => log.blockId === outputId.split('.')[0]) // Extract block ID correctly - handle both formats:
) // - "blockId" (direct block ID)
// - "blockId_response.result" (block ID with path)
const blockIdForOutput = outputId.includes('_')
? outputId.split('_')[0]
: outputId.split('.')[0]
return nonStreamingLogs.some((log) => log.blockId === blockIdForOutput)
})
for (const outputId of outputsToRender) { for (const outputId of outputsToRender) {
const blockIdForOutput = outputId.split('.')[0] const blockIdForOutput = outputId.includes('_')
? outputId.split('_')[0]
: outputId.split('.')[0]
const path = outputId.substring(blockIdForOutput.length + 1) const path = outputId.substring(blockIdForOutput.length + 1)
const log = nonStreamingLogs.find((l) => l.blockId === blockIdForOutput) const log = nonStreamingLogs.find((l) => l.blockId === blockIdForOutput)

View File

@@ -53,13 +53,41 @@ export function OutputSelect({
const addOutput = (path: string, outputObj: any, prefix = '') => { const addOutput = (path: string, outputObj: any, prefix = '') => {
const fullPath = prefix ? `${prefix}.${path}` : path const fullPath = prefix ? `${prefix}.${path}` : path
if (typeof outputObj === 'object' && outputObj !== null) { // If not an object or is null, treat as leaf node
// For objects, recursively add each property if (typeof outputObj !== 'object' || outputObj === null) {
const output = {
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name || `Block ${block.id}`,
blockType: block.type,
path: fullPath,
}
outputs.push(output)
return
}
// If has 'type' property, treat as schema definition (leaf node)
if ('type' in outputObj && typeof outputObj.type === 'string') {
const output = {
id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`,
blockId: block.id,
blockName: block.name || `Block ${block.id}`,
blockType: block.type,
path: fullPath,
}
outputs.push(output)
return
}
// For objects without type, recursively add each property
if (!Array.isArray(outputObj)) {
Object.entries(outputObj).forEach(([key, value]) => { Object.entries(outputObj).forEach(([key, value]) => {
addOutput(key, value, fullPath) addOutput(key, value, fullPath)
}) })
} else { } else {
// Add leaf node as output option // For arrays, treat as leaf node
outputs.push({ outputs.push({
id: `${block.id}_${fullPath}`, id: `${block.id}_${fullPath}`,
label: `${blockName}.${fullPath}`, label: `${blockName}.${fullPath}`,
@@ -71,10 +99,10 @@ export function OutputSelect({
} }
} }
// Start with the response object // Process all output properties directly (flattened structure)
if (block.outputs.response) { Object.entries(block.outputs).forEach(([key, value]) => {
addOutput('response', block.outputs.response) addOutput(key, value)
} })
} }
}) })

View File

@@ -145,11 +145,13 @@ export const Toolbar = React.memo(() => {
{blocks.map((block) => ( {blocks.map((block) => (
<ToolbarBlock key={block.type} config={block} disabled={!userPermissions.canEdit} /> <ToolbarBlock key={block.type} config={block} disabled={!userPermissions.canEdit} />
))} ))}
{activeTab === 'blocks' && !searchQuery && ( {((activeTab === 'blocks' && !searchQuery) ||
<> (searchQuery && 'loop'.includes(searchQuery.toLowerCase()))) && (
<LoopToolbarItem disabled={!userPermissions.canEdit} /> <LoopToolbarItem disabled={!userPermissions.canEdit} />
<ParallelToolbarItem disabled={!userPermissions.canEdit} /> )}
</> {((activeTab === 'blocks' && !searchQuery) ||
(searchQuery && 'parallel'.includes(searchQuery.toLowerCase()))) && (
<ParallelToolbarItem disabled={!userPermissions.canEdit} />
)} )}
</div> </div>
</div> </div>

View File

@@ -4,10 +4,11 @@ import {
type ConnectedBlock, type ConnectedBlock,
useBlockConnections, useBlockConnections,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections' } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { getBlock } from '@/blocks'
interface ConnectionBlocksProps { interface ConnectionBlocksProps {
blockId: string blockId: string
horizontalHandles: boolean
setIsConnecting: (isConnecting: boolean) => void setIsConnecting: (isConnecting: boolean) => void
isDisabled?: boolean isDisabled?: boolean
} }
@@ -20,6 +21,7 @@ interface ResponseField {
export function ConnectionBlocks({ export function ConnectionBlocks({
blockId, blockId,
horizontalHandles,
setIsConnecting, setIsConnecting,
isDisabled = false, isDisabled = false,
}: ConnectionBlocksProps) { }: ConnectionBlocksProps) {
@@ -39,6 +41,10 @@ export function ConnectionBlocks({
e.stopPropagation() // Prevent parent drag handlers from firing e.stopPropagation() // Prevent parent drag handlers from firing
setIsConnecting(true) setIsConnecting(true)
// If no specific field is provided, use all available output types
const outputType = field ? field.name : connection.outputType
e.dataTransfer.setData( e.dataTransfer.setData(
'application/json', 'application/json',
JSON.stringify({ JSON.stringify({
@@ -46,9 +52,13 @@ export function ConnectionBlocks({
connectionData: { connectionData: {
id: connection.id, id: connection.id,
name: connection.name, name: connection.name,
outputType: field ? field.name : connection.outputType, outputType: outputType,
sourceBlockId: connection.id, sourceBlockId: connection.id,
fieldType: field?.type, fieldType: field?.type,
// Include all available output types for reference
allOutputTypes: Array.isArray(connection.outputType)
? connection.outputType
: [connection.outputType],
}, },
}) })
) )
@@ -59,147 +69,59 @@ export function ConnectionBlocks({
setIsConnecting(false) setIsConnecting(false)
} }
// Helper function to extract fields from JSON Schema // Use connections in distance order (already sorted and deduplicated by the hook)
const extractFieldsFromSchema = (connection: ConnectedBlock): ResponseField[] => { const sortedConnections = incomingConnections
// Handle legacy format with fields array
if (connection.responseFormat?.fields) {
return connection.responseFormat.fields
}
// Handle new JSON Schema format
const schema = connection.responseFormat?.schema || connection.responseFormat
// Safely check if schema and properties exist
if (
!schema ||
typeof schema !== 'object' ||
!('properties' in schema) ||
typeof schema.properties !== 'object'
) {
return []
}
return Object.entries(schema.properties).map(([name, prop]: [string, any]) => ({
name,
type: Array.isArray(prop) ? 'array' : prop.type || 'string',
description: prop.description,
}))
}
// Extract fields from starter block input format
const extractFieldsFromStarterInput = (connection: ConnectedBlock): ResponseField[] => {
// Only process for starter blocks
if (connection.type !== 'starter') return []
try {
// Get input format from subblock store
const inputFormat = useSubBlockStore.getState().getValue(connection.id, 'inputFormat')
// Make sure we have a valid input format
if (!inputFormat || !Array.isArray(inputFormat) || inputFormat.length === 0) {
return [{ name: 'input', type: 'any' }]
}
// Check if any fields have been configured with names
const hasConfiguredFields = inputFormat.some(
(field: any) => field.name && field.name.trim() !== ''
)
// If no fields have been configured, return the default input field
if (!hasConfiguredFields) {
return [{ name: 'input', type: 'any' }]
}
// Map input fields to response fields
return inputFormat.map((field: any) => ({
name: `input.${field.name}`,
type: field.type || 'string',
description: field.description,
}))
} catch (e) {
console.error('Error extracting fields from starter input format:', e)
return [{ name: 'input', type: 'any' }]
}
}
// Deduplicate connections by ID
const connectionMap = incomingConnections.reduce(
(acc, connection) => {
acc[connection.id] = connection
return acc
},
{} as Record<string, ConnectedBlock>
)
// Sort connections by name
const sortedConnections = Object.values(connectionMap).sort((a, b) =>
a.name.localeCompare(b.name)
)
// Helper function to render a connection card // Helper function to render a connection card
const renderConnectionCard = (connection: ConnectedBlock, field?: ResponseField) => { const renderConnectionCard = (connection: ConnectedBlock) => {
const displayName = connection.name.replace(/\s+/g, '').toLowerCase() // Get block configuration for icon and color
const blockConfig = getBlock(connection.type)
const displayName = connection.name // Use the actual block name instead of transforming it
const Icon = blockConfig?.icon
const bgColor = blockConfig?.bgColor || '#6B7280' // Fallback to gray
return ( return (
<Card <Card
key={`${field ? field.name : connection.id}`} key={`${connection.id}-${connection.name}`}
draggable={!isDisabled} draggable={!isDisabled}
onDragStart={(e) => handleDragStart(e, connection, field)} onDragStart={(e) => handleDragStart(e, connection)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
className={cn( className={cn(
'group flex w-max items-center rounded-lg border bg-card p-2 shadow-sm transition-colors', 'group flex w-max items-center gap-2 rounded-lg border bg-card p-2 shadow-sm transition-colors',
!isDisabled !isDisabled
? 'cursor-grab hover:bg-accent/50 active:cursor-grabbing' ? 'cursor-grab hover:bg-accent/50 active:cursor-grabbing'
: 'cursor-not-allowed opacity-60' : 'cursor-not-allowed opacity-60'
)} )}
> >
{/* Block icon with color */}
{Icon && (
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{ backgroundColor: bgColor }}
>
<Icon className='h-3 w-3 text-white' />
</div>
)}
<div className='text-sm'> <div className='text-sm'>
<span className='font-medium leading-none'>{displayName}</span> <span className='font-medium leading-none'>{displayName}</span>
<span className='text-muted-foreground'>
{field
? `.${field.name}`
: typeof connection.outputType === 'string'
? `.${connection.outputType}`
: ''}
</span>
</div> </div>
</Card> </Card>
) )
} }
return ( // Generate all connection cards - one per block, not per output field
<div className='absolute top-0 right-full flex max-h-[400px] flex-col items-end space-y-2 overflow-y-auto pr-5'> const connectionCards: React.ReactNode[] = []
{sortedConnections.map((connection, index) => {
// Special handling for starter blocks with input format
if (connection.type === 'starter') {
const starterFields = extractFieldsFromStarterInput(connection)
if (starterFields.length > 0) { sortedConnections.forEach((connection) => {
return ( connectionCards.push(renderConnectionCard(connection))
<div key={connection.id} className='space-y-2'> })
{starterFields.map((field) => renderConnectionCard(connection, field))}
</div>
)
}
}
// Regular connection handling // Position and layout based on handle orientation - reverse of ports
return ( // When ports are horizontal: connection blocks on top, aligned to left, closest blocks on bottom row
<div key={`${connection.id}-${index}`} className='space-y-2'> // When ports are vertical (default): connection blocks on left, stack vertically, aligned to right
{Array.isArray(connection.outputType) const containerClasses = horizontalHandles
? // Handle array of field names ? 'absolute bottom-full left-0 flex max-w-[600px] flex-wrap-reverse gap-2 pb-3'
connection.outputType.map((fieldName) => { : 'absolute top-0 right-full flex max-h-[400px] max-w-[200px] flex-col items-end gap-2 overflow-y-auto pr-3'
// Try to find field in response format
const fields = extractFieldsFromSchema(connection)
const field = fields.find((f) => f.name === fieldName) || {
name: fieldName,
type: 'string',
}
return renderConnectionCard(connection, field) return <div className={containerClasses}>{connectionCards}</div>
})
: renderConnectionCard(connection)}
</div>
)
})}
</div>
)
} }

View File

@@ -19,7 +19,6 @@ import {
type OAuthProvider, type OAuthProvider,
parseProvider, parseProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
const logger = createLogger('OAuthRequiredModal') const logger = createLogger('OAuthRequiredModal')
@@ -157,42 +156,11 @@ export function OAuthRequiredModal({
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile') (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
) )
const handleRedirectToSettings = () => {
try {
// Determine the appropriate serviceId and providerId
const providerId = getProviderIdFromServiceId(effectiveServiceId)
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
saveToStorage<boolean>('from_oauth_modal', true)
// Close the modal
onClose()
// Open the settings modal with the credentials tab
const event = new CustomEvent('open-settings', {
detail: { tab: 'credentials' },
})
window.dispatchEvent(event)
} catch (error) {
logger.error('Error redirecting to settings:', { error })
}
}
const handleConnectDirectly = async () => { const handleConnectDirectly = async () => {
try { try {
// Determine the appropriate serviceId and providerId // Determine the appropriate serviceId and providerId
const providerId = getProviderIdFromServiceId(effectiveServiceId) const providerId = getProviderIdFromServiceId(effectiveServiceId)
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Close the modal // Close the modal
onClose() onClose()
@@ -258,14 +226,6 @@ export function OAuthRequiredModal({
<Button type='button' onClick={handleConnectDirectly} className='sm:order-3'> <Button type='button' onClick={handleConnectDirectly} className='sm:order-3'>
Connect Now Connect Now
</Button> </Button>
<Button
type='button'
variant='secondary'
onClick={handleRedirectToSettings}
className='sm:order-2'
>
Go to Settings
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -21,31 +21,24 @@ import {
type OAuthProvider, type OAuthProvider,
parseProvider, parseProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence' import type { SubBlockConfig } from '@/blocks/types'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { OAuthRequiredModal } from './components/oauth-required-modal' import { OAuthRequiredModal } from './components/oauth-required-modal'
const logger = createLogger('CredentialSelector') const logger = createLogger('CredentialSelector')
interface CredentialSelectorProps { interface CredentialSelectorProps {
value: string blockId: string
onChange: (value: string) => void subBlock: SubBlockConfig
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean disabled?: boolean
serviceId?: string
isPreview?: boolean isPreview?: boolean
previewValue?: any | null previewValue?: any | null
} }
export function CredentialSelector({ export function CredentialSelector({
value, blockId,
onChange, subBlock,
provider,
requiredScopes = [],
label = 'Select credential',
disabled = false, disabled = false,
serviceId,
isPreview = false, isPreview = false,
previewValue, previewValue,
}: CredentialSelectorProps) { }: CredentialSelectorProps) {
@@ -55,14 +48,22 @@ export function CredentialSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('') const [selectedId, setSelectedId] = useState('')
// Use collaborative state management via useSubBlockValue hook
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Extract values from subBlock config
const provider = subBlock.provider as OAuthProvider
const requiredScopes = subBlock.requiredScopes || []
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId
// Get the effective value (preview or store value)
const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue
// Initialize selectedId with the effective value // Initialize selectedId with the effective value
useEffect(() => { useEffect(() => {
if (isPreview && previewValue !== undefined) { setSelectedId(effectiveValue || '')
setSelectedId(previewValue || '') }, [effectiveValue])
} else {
setSelectedId(value)
}
}, [value, isPreview, previewValue])
// Derive service and provider IDs using useMemo // Derive service and provider IDs using useMemo
const effectiveServiceId = useMemo(() => { const effectiveServiceId = useMemo(() => {
@@ -85,7 +86,9 @@ export function CredentialSelector({
// If we have a value but it's not in the credentials, reset it // If we have a value but it's not in the credentials, reset it
if (selectedId && !data.credentials.some((cred: Credential) => cred.id === selectedId)) { if (selectedId && !data.credentials.some((cred: Credential) => cred.id === selectedId)) {
setSelectedId('') setSelectedId('')
onChange('') if (!isPreview) {
setStoreValue('')
}
} }
// Auto-select logic: // Auto-select logic:
@@ -99,11 +102,15 @@ export function CredentialSelector({
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault) const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) { if (defaultCred) {
setSelectedId(defaultCred.id) setSelectedId(defaultCred.id)
onChange(defaultCred.id) if (!isPreview) {
setStoreValue(defaultCred.id)
}
} else if (data.credentials.length === 1) { } else if (data.credentials.length === 1) {
// If only one credential, select it // If only one credential, select it
setSelectedId(data.credentials[0].id) setSelectedId(data.credentials[0].id)
onChange(data.credentials[0].id) if (!isPreview) {
setStoreValue(data.credentials[0].id)
}
} }
} }
} }
@@ -112,7 +119,7 @@ export function CredentialSelector({
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [effectiveProviderId, onChange, selectedId]) }, [effectiveProviderId, selectedId, isPreview, setStoreValue])
// Fetch credentials on initial mount // Fetch credentials on initial mount
useEffect(() => { useEffect(() => {
@@ -121,11 +128,7 @@ export function CredentialSelector({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// Update local state when external value changes // This effect is no longer needed since we're using effectiveValue directly
useEffect(() => {
const currentValue = isPreview ? previewValue : value
setSelectedId(currentValue || '')
}, [value, isPreview, previewValue])
// Listen for visibility changes to update credentials when user returns from settings // Listen for visibility changes to update credentials when user returns from settings
useEffect(() => { useEffect(() => {
@@ -158,19 +161,13 @@ export function CredentialSelector({
const handleSelect = (credentialId: string) => { const handleSelect = (credentialId: string) => {
setSelectedId(credentialId) setSelectedId(credentialId)
if (!isPreview) { if (!isPreview) {
onChange(credentialId) setStoreValue(credentialId)
} }
setOpen(false) setOpen(false)
} }
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', effectiveProviderId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)

View File

@@ -19,7 +19,6 @@ import {
getServiceIdFromScopes, getServiceIdFromScopes,
type OAuthProvider, type OAuthProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
export interface ConfluenceFileInfo { export interface ConfluenceFileInfo {
@@ -355,15 +354,6 @@ export function ConfluenceFileSelector({
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)

View File

@@ -24,7 +24,6 @@ import {
type OAuthProvider, type OAuthProvider,
parseProvider, parseProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = createLogger('GoogleDrivePicker') const logger = createLogger('GoogleDrivePicker')
@@ -79,6 +78,7 @@ export function GoogleDrivePicker({
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false) const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false) const initialFetchRef = useRef(false)
const [openPicker, _authResponse] = useDrivePicker() const [openPicker, _authResponse] = useDrivePicker()
@@ -97,6 +97,7 @@ export function GoogleDrivePicker({
// Fetch available credentials for this provider // Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => { const fetchCredentials = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
setCredentialsLoaded(false)
try { try {
const providerId = getProviderId() const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
@@ -128,6 +129,7 @@ export function GoogleDrivePicker({
logger.error('Error fetching credentials:', { error }) logger.error('Error fetching credentials:', { error })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
setCredentialsLoaded(true)
} }
}, [provider, getProviderId, selectedCredentialId]) }, [provider, getProviderId, selectedCredentialId])
@@ -154,9 +156,16 @@ export function GoogleDrivePicker({
return data.file return data.file
} }
} else { } else {
logger.error('Error fetching file by ID:', { const errorText = await response.text()
error: await response.text(), logger.error('Error fetching file by ID:', { error: errorText })
})
// If file not found or access denied, clear the selection
if (response.status === 404 || response.status === 403) {
logger.info('File not accessible, clearing selection')
setSelectedFileId('')
onChange('')
onFileInfoChange?.(null)
}
} }
return null return null
} catch (error) { } catch (error) {
@@ -166,7 +175,7 @@ export function GoogleDrivePicker({
setIsLoadingSelectedFile(false) setIsLoadingSelectedFile(false)
} }
}, },
[selectedCredentialId, onFileInfoChange] [selectedCredentialId, onChange, onFileInfoChange]
) )
// Fetch credentials on initial mount // Fetch credentials on initial mount
@@ -177,20 +186,61 @@ export function GoogleDrivePicker({
} }
}, [fetchCredentials]) }, [fetchCredentials])
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// If we have a file ID selected and credentials are ready but we still don't have the file info, fetch it
if (value && selectedCredentialId && !selectedFile) {
fetchFileById(value)
}
}, [value, selectedCredentialId, selectedFile, fetchFileById])
// Keep internal selectedFileId in sync with the value prop // Keep internal selectedFileId in sync with the value prop
useEffect(() => { useEffect(() => {
if (value !== selectedFileId) { if (value !== selectedFileId) {
const previousFileId = selectedFileId
setSelectedFileId(value) setSelectedFileId(value)
// Only clear selected file info if we had a different file before (not initial load)
if (previousFileId && previousFileId !== value && selectedFile) {
setSelectedFile(null)
}
} }
}, [value]) }, [value, selectedFileId, selectedFile])
// Track previous credential ID to detect changes
const prevCredentialIdRef = useRef<string>('')
// Clear selected file when credentials are removed or changed
useEffect(() => {
const prevCredentialId = prevCredentialIdRef.current
prevCredentialIdRef.current = selectedCredentialId
if (!selectedCredentialId) {
// No credentials - clear everything
if (selectedFile) {
setSelectedFile(null)
setSelectedFileId('')
onChange('')
}
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
}
}, [selectedCredentialId, selectedFile, onChange])
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
!selectedFile &&
!isLoadingSelectedFile
) {
fetchFileById(value)
}
}, [
value,
selectedCredentialId,
credentialsLoaded,
selectedFile,
isLoadingSelectedFile,
fetchFileById,
])
// Fetch the access token for the selected credential // Fetch the access token for the selected credential
const fetchAccessToken = async (): Promise<string | null> => { const fetchAccessToken = async (): Promise<string | null> => {
@@ -286,15 +336,6 @@ export function GoogleDrivePicker({
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)
@@ -399,7 +440,7 @@ export function GoogleDrivePicker({
{getFileIcon(selectedFile, 'sm')} {getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span> <span className='truncate font-normal'>{selectedFile.name}</span>
</div> </div>
) : selectedFileId && (isLoadingSelectedFile || !selectedCredentialId) ? ( ) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<RefreshCw className='h-4 w-4 animate-spin' /> <RefreshCw className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground'>Loading document...</span> <span className='text-muted-foreground'>Loading document...</span>

View File

@@ -20,7 +20,6 @@ import {
getServiceIdFromScopes, getServiceIdFromScopes,
type OAuthProvider, type OAuthProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = new Logger('jira_issue_selector') const logger = new Logger('jira_issue_selector')
@@ -420,15 +419,6 @@ export function JiraIssueSelector({
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)

View File

@@ -23,7 +23,6 @@ import {
type OAuthProvider, type OAuthProvider,
parseProvider, parseProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = createLogger('MicrosoftFileSelector') const logger = createLogger('MicrosoftFileSelector')
@@ -75,6 +74,7 @@ export function MicrosoftFileSelector({
const [availableFiles, setAvailableFiles] = useState<MicrosoftFileInfo[]>([]) const [availableFiles, setAvailableFiles] = useState<MicrosoftFileInfo[]>([])
const [searchQuery, setSearchQuery] = useState<string>('') const [searchQuery, setSearchQuery] = useState<string>('')
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false) const initialFetchRef = useRef(false)
// Determine the appropriate service ID based on provider and scopes // Determine the appropriate service ID based on provider and scopes
@@ -92,6 +92,7 @@ export function MicrosoftFileSelector({
// Fetch available credentials for this provider // Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => { const fetchCredentials = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
setCredentialsLoaded(false)
try { try {
const providerId = getProviderId() const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`) const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
@@ -123,6 +124,7 @@ export function MicrosoftFileSelector({
logger.error('Error fetching credentials:', { error }) logger.error('Error fetching credentials:', { error })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
setCredentialsLoaded(true)
} }
}, [provider, getProviderId, selectedCredentialId]) }, [provider, getProviderId, selectedCredentialId])
@@ -183,9 +185,16 @@ export function MicrosoftFileSelector({
return data.file return data.file
} }
} else { } else {
logger.error('Error fetching file by ID:', { const errorText = await response.text()
error: await response.text(), logger.error('Error fetching file by ID:', { error: errorText })
})
// If file not found or access denied, clear the selection
if (response.status === 404 || response.status === 403) {
logger.info('File not accessible, clearing selection')
setSelectedFileId('')
onChange('')
onFileInfoChange?.(null)
}
} }
return null return null
} catch (error) { } catch (error) {
@@ -224,20 +233,61 @@ export function MicrosoftFileSelector({
} }
}, [searchQuery, selectedCredentialId, fetchAvailableFiles]) }, [searchQuery, selectedCredentialId, fetchAvailableFiles])
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// If we have a file ID selected and credentials are ready but we still don't have the file info, fetch it
if (value && selectedCredentialId && !selectedFile) {
fetchFileById(value)
}
}, [value, selectedCredentialId, selectedFile, fetchFileById])
// Keep internal selectedFileId in sync with the value prop // Keep internal selectedFileId in sync with the value prop
useEffect(() => { useEffect(() => {
if (value !== selectedFileId) { if (value !== selectedFileId) {
const previousFileId = selectedFileId
setSelectedFileId(value) setSelectedFileId(value)
// Only clear selected file info if we had a different file before (not initial load)
if (previousFileId && previousFileId !== value && selectedFile) {
setSelectedFile(null)
}
} }
}, [value]) }, [value, selectedFileId, selectedFile])
// Track previous credential ID to detect changes
const prevCredentialIdRef = useRef<string>('')
// Clear selected file when credentials are removed or changed
useEffect(() => {
const prevCredentialId = prevCredentialIdRef.current
prevCredentialIdRef.current = selectedCredentialId
if (!selectedCredentialId) {
// No credentials - clear everything
if (selectedFile) {
setSelectedFile(null)
setSelectedFileId('')
onChange('')
}
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
}
}, [selectedCredentialId, selectedFile, onChange])
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
!selectedFile &&
!isLoadingSelectedFile
) {
fetchFileById(value)
}
}, [
value,
selectedCredentialId,
credentialsLoaded,
selectedFile,
isLoadingSelectedFile,
fetchFileById,
])
// Handle selecting a file from the available files // Handle selecting a file from the available files
const handleFileSelect = (file: MicrosoftFileInfo) => { const handleFileSelect = (file: MicrosoftFileInfo) => {
@@ -251,15 +301,6 @@ export function MicrosoftFileSelector({
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)
@@ -381,7 +422,7 @@ export function MicrosoftFileSelector({
{getFileIcon(selectedFile, 'sm')} {getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span> <span className='truncate font-normal'>{selectedFile.name}</span>
</div> </div>
) : selectedFileId && (isLoadingSelectedFile || !selectedCredentialId) ? ( ) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<RefreshCw className='h-4 w-4 animate-spin' /> <RefreshCw className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground'>Loading document...</span> <span className='text-muted-foreground'>Loading document...</span>

View File

@@ -20,7 +20,6 @@ import {
getServiceIdFromScopes, getServiceIdFromScopes,
type OAuthProvider, type OAuthProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = new Logger('TeamsMessageSelector') const logger = new Logger('TeamsMessageSelector')
@@ -399,15 +398,6 @@ export function TeamsMessageSelector({
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)

View File

@@ -16,7 +16,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth' import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { saveToStorage } from '@/stores/workflows/persistence'
const logger = createLogger('FolderSelector') const logger = createLogger('FolderSelector')
@@ -274,15 +273,6 @@ export function FolderSelector({
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)

View File

@@ -20,7 +20,6 @@ import {
getServiceIdFromScopes, getServiceIdFromScopes,
type OAuthProvider, type OAuthProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = new Logger('jira_project_selector') const logger = new Logger('jira_project_selector')
@@ -371,15 +370,6 @@ export function JiraProjectSelector({
// Handle adding a new credential // Handle adding a new credential
const handleAddCredential = () => { const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal // Show the OAuth modal
setShowOAuthModal(true) setShowOAuthModal(true)
setOpen(false) setOpen(false)

View File

@@ -290,7 +290,13 @@ export function ResponseFormat({
{showPreview && ( {showPreview && (
<div className='rounded border bg-muted/30 p-2'> <div className='rounded border bg-muted/30 p-2'>
<pre className='max-h-32 overflow-auto text-xs'> <pre className='max-h-32 overflow-auto text-xs'>
{JSON.stringify(generateJSON(properties), null, 2)} {(() => {
try {
return JSON.stringify(generateJSON(properties), null, 2)
} catch (error) {
return `Error generating preview: ${error instanceof Error ? error.message : 'Unknown error'}`
}
})()}
</pre> </pre>
</div> </div>
)} )}

View File

@@ -26,10 +26,10 @@ interface ScheduleConfigProps {
export function ScheduleConfig({ export function ScheduleConfig({
blockId, blockId,
subBlockId, subBlockId: _subBlockId,
isConnecting, isConnecting,
isPreview = false, isPreview = false,
previewValue, previewValue: _previewValue,
disabled = false, disabled = false,
}: ScheduleConfigProps) { }: ScheduleConfigProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -56,13 +56,7 @@ export function ScheduleConfig({
// Get the startWorkflow value to determine if scheduling is enabled // Get the startWorkflow value to determine if scheduling is enabled
// and expose the setter so we can update it // and expose the setter so we can update it
const [startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow') const [_startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow')
const isScheduleEnabled = startWorkflow === 'schedule'
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Function to check if schedule exists in the database // Function to check if schedule exists in the database
const checkSchedule = async () => { const checkSchedule = async () => {
@@ -110,10 +104,17 @@ export function ScheduleConfig({
// Check for schedule on mount and when relevant dependencies change // Check for schedule on mount and when relevant dependencies change
useEffect(() => { useEffect(() => {
// Always check for schedules regardless of the UI setting // Only check for schedules when workflowId changes or modal opens
// This ensures we detect schedules even when the UI is set to manual // Avoid checking on every scheduleType change to prevent excessive API calls
checkSchedule() if (workflowId && (isModalOpen || refreshCounter > 0)) {
}, [workflowId, scheduleType, isModalOpen, refreshCounter]) checkSchedule()
}
// Cleanup function to reset loading state
return () => {
setIsLoading(false)
}
}, [workflowId, isModalOpen, refreshCounter])
// Format the schedule information for display // Format the schedule information for display
const getScheduleInfo = () => { const getScheduleInfo = () => {

View File

@@ -0,0 +1,213 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console-logger'
import {
type Credential,
OAUTH_PROVIDERS,
type OAuthProvider,
type OAuthService,
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = createLogger('ToolCredentialSelector')
// Helper functions for provider icons and names
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className='h-4 w-4' />
}
// Always use the base provider icon for a more consistent UI
return baseProviderConfig.icon({ className: 'h-4 w-4' })
}
const getProviderName = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (baseProviderConfig) {
return baseProviderConfig.name
}
// Fallback: capitalize the provider name
return providerName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
interface ToolCredentialSelectorProps {
value: string
onChange: (value: string) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
serviceId?: OAuthService
disabled?: boolean
}
export function ToolCredentialSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select account',
serviceId,
disabled = false,
}: ToolCredentialSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
// Update selected ID when value changes
useEffect(() => {
setSelectedId(value)
}, [value])
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/credentials?provider=${provider}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials || [])
// If we have a selected value but it's not in the credentials list, clear it
if (value && !data.credentials?.some((cred: Credential) => cred.id === value)) {
onChange('')
}
} else {
logger.error('Error fetching credentials:', { error: await response.text() })
setCredentials([])
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
setCredentials([])
} finally {
setIsLoading(false)
}
}, [provider, value, onChange])
// Fetch credentials on mount and when provider changes
useEffect(() => {
fetchCredentials()
}, [fetchCredentials])
const handleSelect = (credentialId: string) => {
setSelectedId(credentialId)
onChange(credentialId)
setOpen(false)
}
const handleOAuthClose = () => {
setShowOAuthModal(false)
// Refetch credentials to include any new ones
fetchCredentials()
}
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled}
>
{selectedCredential ? (
<div className='flex items-center gap-2 overflow-hidden'>
{getProviderIcon(provider)}
<span className='truncate font-normal'>{selectedCredential.name}</span>
</div>
) : (
<div className='flex items-center gap-2'>
{getProviderIcon(provider)}
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName(provider)} account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts found.</p>
</div>
)}
</CommandEmpty>
{credentials.length > 0 && (
<CommandGroup>
{credentials.map((credential) => (
<CommandItem
key={credential.id}
value={credential.id}
onSelect={() => handleSelect(credential.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(credential.provider)}
<span className='font-normal'>{credential.name}</span>
</div>
{credential.id === selectedId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup>
<CommandItem onSelect={() => setShowOAuthModal(true)}>
<div className='flex items-center gap-2'>
<Plus className='h-4 w-4' />
<span className='font-normal'>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={handleOAuthClose}
provider={provider}
toolName={label}
requiredScopes={requiredScopes}
serviceId={serviceId}
/>
</>
)
}

View File

@@ -22,10 +22,10 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getTool } from '@/tools/utils' import { getTool } from '@/tools/utils'
import { useSubBlockValue } from '../../hooks/use-sub-block-value' import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { ChannelSelectorInput } from '../channel-selector/channel-selector-input' import { ChannelSelectorInput } from '../channel-selector/channel-selector-input'
import { CredentialSelector } from '../credential-selector/credential-selector'
import { ShortInput } from '../short-input' import { ShortInput } from '../short-input'
import { type CustomTool, CustomToolModal } from './components/custom-tool-modal/custom-tool-modal' import { type CustomTool, CustomToolModal } from './components/custom-tool-modal/custom-tool-modal'
import { ToolCommand } from './components/tool-command/tool-command' import { ToolCommand } from './components/tool-command/tool-command'
import { ToolCredentialSelector } from './components/tool-credential-selector'
interface ToolInputProps { interface ToolInputProps {
blockId: string blockId: string
@@ -347,6 +347,8 @@ export function ToolInput({
const [customToolModalOpen, setCustomToolModalOpen] = useState(false) const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null) const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const isWide = useWorkflowStore((state) => state.blocks[blockId]?.isWide) const isWide = useWorkflowStore((state) => state.blocks[blockId]?.isWide)
const customTools = useCustomToolsStore((state) => state.getAllTools()) const customTools = useCustomToolsStore((state) => state.getAllTools())
const subBlockStore = useSubBlockStore() const subBlockStore = useSubBlockStore()
@@ -668,6 +670,46 @@ export function ToolInput({
) )
} }
const handleDragStart = (e: React.DragEvent, index: number) => {
if (isPreview || disabled) return
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', '')
}
const handleDragOver = (e: React.DragEvent, index: number) => {
if (isPreview || disabled || draggedIndex === null) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverIndex(index)
}
const handleDragEnd = () => {
setDraggedIndex(null)
setDragOverIndex(null)
}
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
if (isPreview || disabled || draggedIndex === null || draggedIndex === dropIndex) return
e.preventDefault()
const newTools = [...selectedTools]
const draggedTool = newTools[draggedIndex]
newTools.splice(draggedIndex, 1)
if (dropIndex === selectedTools.length) {
newTools.push(draggedTool)
} else {
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
newTools.splice(adjustedDropIndex, 0, draggedTool)
}
setStoreValue(newTools)
setDraggedIndex(null)
setDragOverIndex(null)
}
const IconComponent = ({ icon: Icon, className }: { icon: any; className?: string }) => { const IconComponent = ({ icon: Icon, className }: { icon: any; className?: string }) => {
if (!Icon) return null if (!Icon) return null
return <Icon className={className} /> return <Icon className={className} />
@@ -827,9 +869,34 @@ export function ToolInput({
return ( return (
<div <div
key={`${tool.type}-${toolIndex}`} key={`${tool.type}-${toolIndex}`}
className={cn('group flex flex-col', isWide ? 'w-[calc(50%-0.25rem)]' : 'w-full')} className={cn(
'group relative flex flex-col transition-all duration-200 ease-in-out',
isWide ? 'w-[calc(50%-0.25rem)]' : 'w-full',
draggedIndex === toolIndex ? 'scale-95 opacity-40' : '',
dragOverIndex === toolIndex && draggedIndex !== toolIndex && draggedIndex !== null
? 'translate-y-1 transform'
: '',
selectedTools.length > 1 && !isPreview && !disabled
? 'cursor-grab active:cursor-grabbing'
: ''
)}
draggable={!isPreview && !disabled}
onDragStart={(e) => handleDragStart(e, toolIndex)}
onDragOver={(e) => handleDragOver(e, toolIndex)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, toolIndex)}
> >
<div className='flex flex-col overflow-visible rounded-md border bg-card'> {/* Subtle drop indicator - use border highlight instead of separate line */}
<div
className={cn(
'flex flex-col overflow-visible rounded-md border bg-card',
dragOverIndex === toolIndex &&
draggedIndex !== toolIndex &&
draggedIndex !== null
? 'border-t-2 border-t-muted-foreground/40'
: ''
)}
>
<div <div
className={cn( className={cn(
'flex items-center justify-between bg-accent/50 p-2', 'flex items-center justify-between bg-accent/50 p-2',
@@ -993,13 +1060,14 @@ export function ToolInput({
<div className='font-medium text-muted-foreground text-xs'> <div className='font-medium text-muted-foreground text-xs'>
Account Account
</div> </div>
<CredentialSelector <ToolCredentialSelector
value={tool.params.credential || ''} value={tool.params.credential || ''}
onChange={(value) => handleCredentialChange(toolIndex, value)} onChange={(value) => handleCredentialChange(toolIndex, value)}
provider={oauthConfig.provider as OAuthProvider} provider={oauthConfig.provider as OAuthProvider}
requiredScopes={oauthConfig.additionalScopes || []} requiredScopes={oauthConfig.additionalScopes || []}
label={`Select ${oauthConfig.provider} account`} label={`Select ${oauthConfig.provider} account`}
serviceId={oauthConfig.provider} serviceId={oauthConfig.provider}
disabled={disabled}
/> />
</div> </div>
) )
@@ -1091,6 +1159,20 @@ export function ToolInput({
) )
})} })}
{/* Drop zone for the end of the list */}
{selectedTools.length > 0 && draggedIndex !== null && (
<div
className={cn(
'h-2 w-full rounded transition-all duration-200 ease-in-out',
dragOverIndex === selectedTools.length
? 'border-b-2 border-b-muted-foreground/40'
: ''
)}
onDragOver={(e) => handleDragOver(e, selectedTools.length)}
onDrop={(e) => handleDrop(e, selectedTools.length)}
/>
)}
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button

View File

@@ -16,7 +16,7 @@ import { createLogger } from '@/lib/logs/console-logger'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../../hooks/use-sub-block-value' import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { CredentialSelector } from '../credential-selector/credential-selector' import { ToolCredentialSelector } from '../tool-input/components/tool-credential-selector'
import { WebhookModal } from './components/webhook-modal' import { WebhookModal } from './components/webhook-modal'
const logger = createLogger('WebhookConfig') const logger = createLogger('WebhookConfig')
@@ -564,7 +564,7 @@ export function WebhookConfig({
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>} {error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}
<div className='mb-3'> <div className='mb-3'>
<CredentialSelector <ToolCredentialSelector
value={gmailCredentialId} value={gmailCredentialId}
onChange={handleCredentialChange} onChange={handleCredentialChange}
provider='google-email' provider='google-email'

View File

@@ -297,27 +297,11 @@ export function SubBlock({
case 'oauth-input': case 'oauth-input':
return ( return (
<CredentialSelector <CredentialSelector
value={ blockId={blockId}
isPreview ? previewValue || '' : typeof config.value === 'string' ? config.value : '' subBlock={config}
}
onChange={(value) => {
// Only allow changes in non-preview mode and when not disabled
if (!isPreview && !disabled) {
const event = new CustomEvent('update-subblock-value', {
detail: {
blockId,
subBlockId: config.id,
value,
},
})
window.dispatchEvent(event)
}
}}
provider={config.provider as any}
requiredScopes={config.requiredScopes || []}
label={config.placeholder || 'Select a credential'}
serviceId={config.serviceId}
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
/> />
) )
case 'file-selector': case 'file-selector':

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { BookOpen, Code, Info, RectangleHorizontal, RectangleVertical } from 'lucide-react' import { BookOpen, Code, Info, RectangleHorizontal, RectangleVertical } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow' import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -83,6 +84,11 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
const isActiveBlock = useExecutionStore((state) => state.activeBlockIds.has(id)) const isActiveBlock = useExecutionStore((state) => state.activeBlockIds.has(id))
const isActive = dataIsActive || isActiveBlock const isActive = dataIsActive || isActiveBlock
// Get the current workflow ID from URL params instead of global state
// This prevents race conditions when switching workflows rapidly
const params = useParams()
const currentWorkflowId = params.workflowId as string
const reactivateSchedule = async (scheduleId: string) => { const reactivateSchedule = async (scheduleId: string) => {
try { try {
const response = await fetch(`/api/schedules/${scheduleId}`, { const response = await fetch(`/api/schedules/${scheduleId}`, {
@@ -94,7 +100,10 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}) })
if (response.ok) { if (response.ok) {
fetchScheduleInfo() // Use the current workflow ID from params instead of global state
if (currentWorkflowId) {
fetchScheduleInfo(currentWorkflowId)
}
} else { } else {
console.error('Failed to reactivate schedule') console.error('Failed to reactivate schedule')
} }
@@ -103,11 +112,11 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
} }
} }
const fetchScheduleInfo = async () => { const fetchScheduleInfo = async (workflowId: string) => {
if (!workflowId) return
try { try {
setIsLoadingScheduleInfo(true) setIsLoadingScheduleInfo(true)
const workflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!workflowId) return
const response = await fetch(`/api/schedules?workflowId=${workflowId}&mode=schedule`, { const response = await fetch(`/api/schedules?workflowId=${workflowId}&mode=schedule`, {
cache: 'no-store', cache: 'no-store',
@@ -176,12 +185,18 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
} }
useEffect(() => { useEffect(() => {
if (type === 'starter') { if (type === 'starter' && currentWorkflowId) {
fetchScheduleInfo() fetchScheduleInfo(currentWorkflowId)
} else { } else {
setScheduleInfo(null) setScheduleInfo(null)
setIsLoadingScheduleInfo(false) // Reset loading state when not a starter block
} }
}, [type])
// Cleanup function to reset loading state when component unmounts or workflow changes
return () => {
setIsLoadingScheduleInfo(false)
}
}, [type, currentWorkflowId])
// Get webhook information for the tooltip // Get webhook information for the tooltip
useEffect(() => { useEffect(() => {
@@ -436,6 +451,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
blockId={id} blockId={id}
setIsConnecting={setIsConnecting} setIsConnecting={setIsConnecting}
isDisabled={!userPermissions.canEdit} isDisabled={!userPermissions.canEdit}
horizontalHandles={horizontalHandles}
/> />
{/* Input Handle - Don't show for starter blocks */} {/* Input Handle - Don't show for starter blocks */}
@@ -683,7 +699,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
{Object.entries(config.outputs).map(([key, value]) => ( {Object.entries(config.outputs).map(([key, value]) => (
<div key={key} className='mb-1'> <div key={key} className='mb-1'>
<span className='text-muted-foreground'>{key}</span>{' '} <span className='text-muted-foreground'>{key}</span>{' '}
{typeof value.type === 'object' ? ( {typeof value === 'object' ? (
<div className='mt-1 pl-3'> <div className='mt-1 pl-3'>
{Object.entries(value.type).map(([typeKey, typeValue]) => ( {Object.entries(value.type).map(([typeKey, typeValue]) => (
<div key={typeKey} className='flex items-start'> <div key={typeKey} className='flex items-start'>
@@ -697,7 +713,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
))} ))}
</div> </div>
) : ( ) : (
<span className='text-green-500'>{value.type as string}</span> <span className='text-green-500'>{value as string}</span>
)} )}
</div> </div>
))} ))}

View File

@@ -1,4 +1,5 @@
import { shallow } from 'zustand/shallow' import { shallow } from 'zustand/shallow'
import { BlockPathCalculator } from '@/lib/block-path-calculator'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -28,6 +29,35 @@ export interface ConnectedBlock {
} }
} }
function parseResponseFormatSafely(responseFormatValue: any, blockId: string): any {
if (!responseFormatValue) {
return undefined
}
if (typeof responseFormatValue === 'object' && responseFormatValue !== null) {
return responseFormatValue
}
if (typeof responseFormatValue === 'string') {
const trimmedValue = responseFormatValue.trim()
if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) {
return trimmedValue
}
if (trimmedValue === '') {
return undefined
}
try {
return JSON.parse(trimmedValue)
} catch (error) {
return undefined
}
}
return undefined
}
// Helper function to extract fields from JSON Schema // Helper function to extract fields from JSON Schema
function extractFieldsFromSchema(schema: any): Field[] { function extractFieldsFromSchema(schema: any): Field[] {
if (!schema || typeof schema !== 'object') { if (!schema || typeof schema !== 'object') {
@@ -53,63 +83,6 @@ function extractFieldsFromSchema(schema: any): Field[] {
})) }))
} }
/**
* Finds all blocks along paths leading to the target block
* This is a reverse traversal from the target node to find all ancestors
* along connected paths
* @param edges - List of all edges in the graph
* @param targetNodeId - ID of the target block we're finding connections for
* @returns Array of unique ancestor node IDs
*/
function findAllPathNodes(edges: any[], targetNodeId: string): string[] {
// We'll use a reverse topological sort approach by tracking "distance" from target
const nodeDistances = new Map<string, number>()
const visited = new Set<string>()
const queue: [string, number][] = [[targetNodeId, 0]] // [nodeId, distance]
const pathNodes = new Set<string>()
// Build a reverse adjacency list for faster traversal
const reverseAdjList: Record<string, string[]> = {}
for (const edge of edges) {
if (!reverseAdjList[edge.target]) {
reverseAdjList[edge.target] = []
}
reverseAdjList[edge.target].push(edge.source)
}
// BFS to find all ancestors and their shortest distance from target
while (queue.length > 0) {
const [currentNodeId, distance] = queue.shift()!
if (visited.has(currentNodeId)) {
// If we've seen this node before, update its distance if this path is shorter
const currentDistance = nodeDistances.get(currentNodeId) || Number.POSITIVE_INFINITY
if (distance < currentDistance) {
nodeDistances.set(currentNodeId, distance)
}
continue
}
visited.add(currentNodeId)
nodeDistances.set(currentNodeId, distance)
// Don't add the target node itself to the results
if (currentNodeId !== targetNodeId) {
pathNodes.add(currentNodeId)
}
// Get all incoming edges from the reverse adjacency list
const incomingNodeIds = reverseAdjList[currentNodeId] || []
// Add all source nodes to the queue with incremented distance
for (const sourceId of incomingNodeIds) {
queue.push([sourceId, distance + 1])
}
}
return Array.from(pathNodes)
}
export function useBlockConnections(blockId: string) { export function useBlockConnections(blockId: string) {
const { edges, blocks } = useWorkflowStore( const { edges, blocks } = useWorkflowStore(
(state) => ({ (state) => ({
@@ -120,7 +93,7 @@ export function useBlockConnections(blockId: string) {
) )
// Find all blocks along paths leading to this block // Find all blocks along paths leading to this block
const allPathNodeIds = findAllPathNodes(edges, blockId) const allPathNodeIds = BlockPathCalculator.findAllPathNodes(edges, blockId)
// Map each path node to a ConnectedBlock structure // Map each path node to a ConnectedBlock structure
const allPathConnections = allPathNodeIds const allPathConnections = allPathNodeIds
@@ -133,15 +106,8 @@ export function useBlockConnections(blockId: string) {
let responseFormat let responseFormat
try { // Safely parse response format with proper error handling
responseFormat = responseFormat = parseResponseFormatSafely(responseFormatValue, sourceId)
typeof responseFormatValue === 'string' && responseFormatValue
? JSON.parse(responseFormatValue)
: responseFormatValue // Handle case where it's already an object
} catch (e) {
logger.error('Failed to parse response format:', { e })
responseFormat = undefined
}
// Get the default output type from the block's outputs // Get the default output type from the block's outputs
const defaultOutputs: Field[] = Object.entries(sourceBlock.outputs || {}).map(([key]) => ({ const defaultOutputs: Field[] = Object.entries(sourceBlock.outputs || {}).map(([key]) => ({
@@ -176,15 +142,8 @@ export function useBlockConnections(blockId: string) {
let responseFormat let responseFormat
try { // Safely parse response format with proper error handling
responseFormat = responseFormat = parseResponseFormatSafely(responseFormatValue, edge.source)
typeof responseFormatValue === 'string' && responseFormatValue
? JSON.parse(responseFormatValue)
: responseFormatValue // Handle case where it's already an object
} catch (e) {
logger.error('Failed to parse response format:', { e })
responseFormat = undefined
}
// Get the default output type from the block's outputs // Get the default output type from the block's outputs
const defaultOutputs: Field[] = Object.entries(sourceBlock.outputs || {}).map(([key]) => ({ const defaultOutputs: Field[] = Object.entries(sourceBlock.outputs || {}).map(([key]) => ({

View File

@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { buildTraceSpans } from '@/lib/logs/trace-spans' import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import { Executor } from '@/executor' import { Executor } from '@/executor'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
@@ -82,9 +83,9 @@ export function useWorkflowExecution() {
} }
// If this was a streaming response and we have the final content, update it // If this was a streaming response and we have the final content, update it
if (streamContent && result.output?.response && typeof streamContent === 'string') { if (streamContent && result.output && typeof streamContent === 'string') {
// Update the content with the final streaming content // Update the content with the final streaming content
enrichedResult.output.response.content = streamContent enrichedResult.output.content = streamContent
// Also update any block logs to include the content where appropriate // Also update any block logs to include the content where appropriate
if (enrichedResult.logs) { if (enrichedResult.logs) {
@@ -97,10 +98,9 @@ export function useWorkflowExecution() {
if ( if (
isStreamingBlock && isStreamingBlock &&
(log.blockType === 'agent' || log.blockType === 'router') && (log.blockType === 'agent' || log.blockType === 'router') &&
log.output?.response log.output
) { )
log.output.response.content = streamContent log.output.content = streamContent
}
} }
} }
} }
@@ -122,7 +122,7 @@ export function useWorkflowExecution() {
return executionId return executionId
} catch (error) { } catch (error) {
logger.error('Error persisting logs:', { error }) logger.error('Error persisting logs:', error)
return executionId return executionId
} }
} }
@@ -212,22 +212,29 @@ export function useWorkflowExecution() {
result.metadata = { duration: 0, startTime: new Date().toISOString() } result.metadata = { duration: 0, startTime: new Date().toISOString() }
} }
;(result.metadata as any).source = 'chat' ;(result.metadata as any).source = 'chat'
result.logs?.forEach((log: BlockLog) => { // Update streamed content and apply tokenization
if (streamedContent.has(log.blockId)) { if (result.logs) {
const content = streamedContent.get(log.blockId) || '' result.logs.forEach((log: BlockLog) => {
if (log.output?.response) { if (streamedContent.has(log.blockId)) {
log.output.response.content = content const content = streamedContent.get(log.blockId) || ''
if (log.output) {
log.output.content = content
}
useConsoleStore.getState().updateConsole(log.blockId, content)
} }
useConsoleStore.getState().updateConsole(log.blockId, content) })
}
}) // Process all logs for streaming tokenization
const processedCount = processStreamingBlockLogs(result.logs, streamedContent)
logger.info(`Processed ${processedCount} blocks for streaming tokenization`)
}
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ event: 'final', data: result })}\n\n`) encoder.encode(`data: ${JSON.stringify({ event: 'final', data: result })}\n\n`)
) )
persistLogs(executionId, result).catch((err) => { persistLogs(executionId, result).catch((err) =>
logger.error('Error persisting logs:', { error: err }) logger.error('Error persisting logs:', err)
}) )
} }
} catch (error: any) { } catch (error: any) {
controller.error(error) controller.error(error)
@@ -437,7 +444,7 @@ export function useWorkflowExecution() {
const errorResult: ExecutionResult = { const errorResult: ExecutionResult = {
success: false, success: false,
output: { response: {} }, output: {},
error: errorMessage, error: errorMessage,
logs: [], logs: [],
} }
@@ -560,7 +567,7 @@ export function useWorkflowExecution() {
// Create error result // Create error result
const errorResult = { const errorResult = {
success: false, success: false,
output: { response: {} }, output: {},
error: errorMessage, error: errorMessage,
logs: debugContext.blockLogs, logs: debugContext.blockLogs,
} }
@@ -647,7 +654,7 @@ export function useWorkflowExecution() {
let currentResult: ExecutionResult = { let currentResult: ExecutionResult = {
success: true, success: true,
output: { response: {} }, output: {},
logs: debugContext.blockLogs, logs: debugContext.blockLogs,
} }
@@ -743,7 +750,7 @@ export function useWorkflowExecution() {
// Create error result // Create error result
const errorResult = { const errorResult = {
success: false, success: false,
output: { response: {} }, output: {},
error: errorMessage, error: errorMessage,
logs: debugContext.blockLogs, logs: debugContext.blockLogs,
} }

View File

@@ -15,9 +15,14 @@ import { useFolderStore } from '@/stores/folders/store'
interface CreateMenuProps { interface CreateMenuProps {
onCreateWorkflow: (folderId?: string) => void onCreateWorkflow: (folderId?: string) => void
isCollapsed?: boolean isCollapsed?: boolean
isCreatingWorkflow?: boolean
} }
export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { export function CreateMenu({
onCreateWorkflow,
isCollapsed,
isCreatingWorkflow = false,
}: CreateMenuProps) {
const [showFolderDialog, setShowFolderDialog] = useState(false) const [showFolderDialog, setShowFolderDialog] = useState(false)
const [folderName, setFolderName] = useState('') const [folderName, setFolderName] = useState('')
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
@@ -73,6 +78,7 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) {
onClick={handleCreateWorkflow} onClick={handleCreateWorkflow}
onMouseEnter={() => setIsHoverOpen(true)} onMouseEnter={() => setIsHoverOpen(true)}
onMouseLeave={() => setIsHoverOpen(false)} onMouseLeave={() => setIsHoverOpen(false)}
disabled={isCreatingWorkflow}
> >
<Plus <Plus
className={cn( className={cn(
@@ -101,11 +107,17 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) {
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
<button <button
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground' className={cn(
'flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
isCreatingWorkflow
? 'cursor-not-allowed opacity-50'
: 'hover:bg-accent hover:text-accent-foreground'
)}
onClick={handleCreateWorkflow} onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow}
> >
<File className='h-4 w-4' /> <File className='h-4 w-4' />
New Workflow {isCreatingWorkflow ? 'Creating...' : 'New Workflow'}
</button> </button>
<button <button
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground' className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'

View File

@@ -14,7 +14,9 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { useFolderStore } from '@/stores/folders/store' import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('FolderContextMenu') const logger = createLogger('FolderContextMenu')
@@ -43,6 +45,9 @@ export function FolderContextMenu({
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
// Get user permissions for the workspace
const userPermissions = useUserPermissionsContext()
const { createFolder, updateFolder, deleteFolder } = useFolderStore() const { createFolder, updateFolder, deleteFolder } = useFolderStore()
const handleCreateWorkflow = () => { const handleCreateWorkflow = () => {
@@ -58,12 +63,17 @@ export function FolderContextMenu({
setShowRenameDialog(true) setShowRenameDialog(true)
} }
const handleDelete = () => { const handleDelete = async () => {
if (onDelete) { if (onDelete) {
onDelete(folderId) onDelete(folderId)
} else { } else {
// Default delete behavior // Default delete behavior with proper error handling
deleteFolder(folderId, workspaceId) try {
await deleteFolder(folderId, workspaceId)
logger.info(`Successfully deleted folder from context menu: ${folderName}`)
} catch (error) {
logger.error('Failed to delete folder from context menu:', { error, folderId, folderName })
}
} }
} }
@@ -129,23 +139,46 @@ export function FolderContextMenu({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end' onClick={(e) => e.stopPropagation()}> <DropdownMenuContent align='end' onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleCreateWorkflow}> {userPermissions.canEdit && (
<File className='mr-2 h-4 w-4' /> <>
New Workflow <DropdownMenuItem onClick={handleCreateWorkflow}>
</DropdownMenuItem> <File className='mr-2 h-4 w-4' />
<DropdownMenuItem onClick={handleCreateSubfolder}> New Workflow
<Folder className='mr-2 h-4 w-4' /> </DropdownMenuItem>
New Subfolder <DropdownMenuItem onClick={handleCreateSubfolder}>
</DropdownMenuItem> <Folder className='mr-2 h-4 w-4' />
<DropdownMenuSeparator /> New Subfolder
<DropdownMenuItem onClick={handleRename}> </DropdownMenuItem>
<Pencil className='mr-2 h-4 w-4' /> <DropdownMenuSeparator />
Rename <DropdownMenuItem onClick={handleRename}>
</DropdownMenuItem> <Pencil className='mr-2 h-4 w-4' />
<DropdownMenuItem onClick={handleDelete} className='text-destructive'> Rename
<Trash2 className='mr-2 h-4 w-4' /> </DropdownMenuItem>
Delete </>
</DropdownMenuItem> )}
{userPermissions.canAdmin ? (
<DropdownMenuItem onClick={handleDelete} className='text-destructive'>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DropdownMenuItem
className='cursor-not-allowed text-muted-foreground opacity-50'
onClick={(e) => e.preventDefault()}
>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Admin access required to delete folders</p>
</TooltipContent>
</Tooltip>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -19,6 +19,7 @@ const TOOLTIPS = {
debugMode: 'Enable visual debugging information during execution.', debugMode: 'Enable visual debugging information during execution.',
autoConnect: 'Automatically connect nodes.', autoConnect: 'Automatically connect nodes.',
autoFillEnvVars: 'Automatically fill API keys.', autoFillEnvVars: 'Automatically fill API keys.',
autoPan: 'Automatically pan to active blocks during workflow execution.',
} }
export function General() { export function General() {
@@ -30,11 +31,13 @@ export function General() {
const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled) const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled)
const isDebugModeEnabled = useGeneralStore((state) => state.isDebugModeEnabled) const isDebugModeEnabled = useGeneralStore((state) => state.isDebugModeEnabled)
const isAutoFillEnvVarsEnabled = useGeneralStore((state) => state.isAutoFillEnvVarsEnabled) const isAutoFillEnvVarsEnabled = useGeneralStore((state) => state.isAutoFillEnvVarsEnabled)
const isAutoPanEnabled = useGeneralStore((state) => state.isAutoPanEnabled)
const setTheme = useGeneralStore((state) => state.setTheme) const setTheme = useGeneralStore((state) => state.setTheme)
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect) const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
const toggleDebugMode = useGeneralStore((state) => state.toggleDebugMode) const toggleDebugMode = useGeneralStore((state) => state.toggleDebugMode)
const toggleAutoFillEnvVars = useGeneralStore((state) => state.toggleAutoFillEnvVars) const toggleAutoFillEnvVars = useGeneralStore((state) => state.toggleAutoFillEnvVars)
const toggleAutoPan = useGeneralStore((state) => state.toggleAutoPan)
const loadSettings = useGeneralStore((state) => state.loadSettings) const loadSettings = useGeneralStore((state) => state.loadSettings)
useEffect(() => { useEffect(() => {
@@ -66,6 +69,12 @@ export function General() {
} }
} }
const handleAutoPanChange = (checked: boolean) => {
if (checked !== isAutoPanEnabled) {
toggleAutoPan()
}
}
const handleRetry = () => { const handleRetry = () => {
setRetryCount((prev) => prev + 1) setRetryCount((prev) => prev + 1)
} }
@@ -200,6 +209,35 @@ export function General() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-pan' className='font-medium'>
Auto-pan during execution
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about auto-pan feature'
disabled={isLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.autoPan}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='auto-pan'
checked={isAutoPanEnabled}
onCheckedChange={handleAutoPanChange}
disabled={isLoading}
/>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -41,6 +41,9 @@ export function Sidebar() {
const { isPending: sessionLoading } = useSession() const { isPending: sessionLoading } = useSession()
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
const isLoading = workflowsLoading || sessionLoading const isLoading = workflowsLoading || sessionLoading
// Add state to prevent multiple simultaneous workflow creations
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
const router = useRouter() const router = useRouter()
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
@@ -108,7 +111,14 @@ export function Sidebar() {
// Create workflow handler // Create workflow handler
const handleCreateWorkflow = async (folderId?: string) => { const handleCreateWorkflow = async (folderId?: string) => {
// Prevent multiple simultaneous workflow creations
if (isCreatingWorkflow) {
logger.info('Workflow creation already in progress, ignoring request')
return
}
try { try {
setIsCreatingWorkflow(true)
const id = await createWorkflow({ const id = await createWorkflow({
workspaceId: workspaceId || undefined, workspaceId: workspaceId || undefined,
folderId: folderId || undefined, folderId: folderId || undefined,
@@ -116,6 +126,8 @@ export function Sidebar() {
router.push(`/workspace/${workspaceId}/w/${id}`) router.push(`/workspace/${workspaceId}/w/${id}`)
} catch (error) { } catch (error) {
logger.error('Error creating workflow:', error) logger.error('Error creating workflow:', error)
} finally {
setIsCreatingWorkflow(false)
} }
} }
@@ -173,7 +185,11 @@ export function Sidebar() {
{isLoading ? <Skeleton className='h-4 w-16' /> : 'Workflows'} {isLoading ? <Skeleton className='h-4 w-16' /> : 'Workflows'}
</h2> </h2>
{!isCollapsed && !isLoading && ( {!isCollapsed && !isLoading && (
<CreateMenu onCreateWorkflow={handleCreateWorkflow} isCollapsed={false} /> <CreateMenu
onCreateWorkflow={handleCreateWorkflow}
isCollapsed={false}
isCreatingWorkflow={isCreatingWorkflow}
/>
)} )}
</div> </div>
<FolderTree <FolderTree

View File

@@ -33,6 +33,7 @@ interface WorkflowPreviewProps {
isPannable?: boolean isPannable?: boolean
defaultPosition?: { x: number; y: number } defaultPosition?: { x: number; y: number }
defaultZoom?: number defaultZoom?: number
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
} }
// Define node types - the components now handle preview mode internally // Define node types - the components now handle preview mode internally
@@ -55,7 +56,24 @@ export function WorkflowPreview({
isPannable = false, isPannable = false,
defaultPosition, defaultPosition,
defaultZoom, defaultZoom,
onNodeClick,
}: WorkflowPreviewProps) { }: WorkflowPreviewProps) {
// Handle migrated logs that don't have complete workflow state
if (!workflowState || !workflowState.blocks || !workflowState.edges) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
const blocksStructure = useMemo( const blocksStructure = useMemo(
() => ({ () => ({
count: Object.keys(workflowState.blocks || {}).length, count: Object.keys(workflowState.blocks || {}).length,
@@ -82,8 +100,8 @@ export function WorkflowPreview({
const edgesStructure = useMemo( const edgesStructure = useMemo(
() => ({ () => ({
count: workflowState.edges.length, count: workflowState.edges?.length || 0,
ids: workflowState.edges.map((e) => e.id).join(','), ids: workflowState.edges?.map((e) => e.id).join(',') || '',
}), }),
[workflowState.edges] [workflowState.edges]
) )
@@ -113,7 +131,7 @@ export function WorkflowPreview({
const nodes: Node[] = useMemo(() => { const nodes: Node[] = useMemo(() => {
const nodeArray: Node[] = [] const nodeArray: Node[] = []
Object.entries(workflowState.blocks).forEach(([blockId, block]) => { Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
if (!block || !block.type) { if (!block || !block.type) {
logger.warn(`Skipping invalid block: ${blockId}`) logger.warn(`Skipping invalid block: ${blockId}`)
return return
@@ -184,7 +202,7 @@ export function WorkflowPreview({
}) })
if (block.type === 'loop') { if (block.type === 'loop') {
const childBlocks = Object.entries(workflowState.blocks).filter( const childBlocks = Object.entries(workflowState.blocks || {}).filter(
([_, childBlock]) => childBlock.data?.parentId === blockId ([_, childBlock]) => childBlock.data?.parentId === blockId
) )
@@ -221,7 +239,7 @@ export function WorkflowPreview({
}, [blocksStructure, loopsStructure, parallelsStructure, showSubBlocks, workflowState.blocks]) }, [blocksStructure, loopsStructure, parallelsStructure, showSubBlocks, workflowState.blocks])
const edges: Edge[] = useMemo(() => { const edges: Edge[] = useMemo(() => {
return workflowState.edges.map((edge) => ({ return (workflowState.edges || []).map((edge) => ({
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
@@ -256,6 +274,14 @@ export function WorkflowPreview({
elementsSelectable={false} elementsSelectable={false}
nodesDraggable={false} nodesDraggable={false}
nodesConnectable={false} nodesConnectable={false}
onNodeClick={
onNodeClick
? (event, node) => {
logger.debug('Node clicked:', { nodeId: node.id, event })
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
> >
<Background /> <Background />
</ReactFlow> </ReactFlow>

View File

@@ -332,25 +332,9 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
tools: { type: 'json', required: false }, tools: { type: 'json', required: false },
}, },
outputs: { outputs: {
response: { content: 'string',
type: { model: 'string',
content: 'string', tokens: 'any',
model: 'string', toolCalls: 'any',
tokens: 'any',
toolCalls: 'any',
},
dependsOn: {
subBlockId: 'responseFormat',
condition: {
whenEmpty: {
content: 'string',
model: 'string',
tokens: 'any',
toolCalls: 'any',
},
whenFilled: 'json',
},
},
},
}, },
} }

View File

@@ -179,12 +179,8 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
}, },
// Output structure depends on the operation, covered by AirtableResponse union type // Output structure depends on the operation, covered by AirtableResponse union type
outputs: { outputs: {
response: { records: 'json', // Optional: for list, create, updateMultiple
type: { record: 'json', // Optional: for get, update single
records: 'json', // Optional: for list, create, updateMultiple metadata: 'json', // Required: present in all responses
record: 'json', // Optional: for get, update single
metadata: 'json', // Required: present in all responses
},
},
}, },
} }

View File

@@ -62,12 +62,8 @@ export const ApiBlock: BlockConfig<RequestResponse> = {
params: { type: 'json', required: false }, params: { type: 'json', required: false },
}, },
outputs: { outputs: {
response: { data: 'any',
type: { status: 'number',
data: 'any', headers: 'json',
status: 'number',
headers: 'json',
},
},
}, },
} }

View File

@@ -112,13 +112,9 @@ export const AutoblocksBlock: BlockConfig<AutoblocksResponse> = {
environment: { type: 'string', required: true }, environment: { type: 'string', required: true },
}, },
outputs: { outputs: {
response: { promptId: 'string',
type: { version: 'string',
promptId: 'string', renderedPrompt: 'string',
version: 'string', templates: 'json',
renderedPrompt: 'string',
templates: 'json',
},
},
}, },
} }

View File

@@ -0,0 +1,316 @@
import { S3Icon } from '@/components/icons'
import type { ToolResponse } from '@/tools/types'
import type { BlockConfig } from '../types'
// Define the expected response type for AWS Lambda operations
interface AWSLambdaResponse extends ToolResponse {
output: {
functionArn: string
functionName: string
endpointName?: string
endpointUrl?: string
runtime: string
region: string
status: string
lastModified: string
codeSize: number
description: string
timeout: number
memorySize: number
environment: Record<string, string>
tags: Record<string, string>
codeFiles: Record<string, string>
handler: string
apiGatewayId?: string
stageName?: string
}
}
export const AWSLambdaBlock: BlockConfig<AWSLambdaResponse> = {
type: 'aws_lambda',
name: 'AWS Lambda',
description: 'Deploy and manage AWS Lambda functions',
longDescription:
'Create, update, and manage AWS Lambda functions with automatic deployment. Configure runtime environments, memory allocation, timeout settings, and environment variables for serverless function execution. Use fetch to retrieve existing function details and code files to understand the current state, then deploy with any desired changes to the function configuration and code.',
docsLink: 'https://docs.simstudio.ai/tools/aws-lambda',
category: 'tools',
bgColor: '#FF9900',
icon: S3Icon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Fetch', id: 'fetch' },
{ label: 'Create/Update', id: 'create/update' },
{ label: 'Deploy Endpoint', id: 'deploy_endpoint' },
{ label: 'Get Prompts', id: 'getPrompts' },
],
},
{
id: 'accessKeyId',
title: 'AWS Access Key ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter AWS Access Key ID',
password: true,
description: 'AWS Access Key ID for authentication. Required for all operations.',
condition: {
field: 'operation',
value: ['fetch', 'create/update', 'deploy_endpoint'],
},
},
{
id: 'secretAccessKey',
title: 'AWS Secret Access Key',
type: 'short-input',
layout: 'full',
placeholder: 'Enter AWS Secret Access Key',
password: true,
description: 'AWS Secret Access Key for authentication. Required for all operations.',
condition: {
field: 'operation',
value: ['fetch', 'create/update', 'deploy_endpoint'],
},
},
{
id: 'role',
title: 'Role ARN',
type: 'short-input',
layout: 'full',
placeholder: 'Enter the IAM Role ARN for Lambda execution',
password: false,
description:
'IAM Role ARN that the Lambda function will assume during execution. Must have appropriate permissions.',
condition: {
field: 'operation',
value: ['fetch', 'create/update', 'deploy_endpoint'],
},
},
{
id: 'region',
title: 'AWS Region',
type: 'dropdown',
layout: 'full',
options: [
'us-east-1',
'us-east-2',
'us-west-1',
'us-west-2',
'af-south-1',
'ap-east-1',
'ap-south-1',
'ap-northeast-1',
'ap-northeast-2',
'ap-northeast-3',
'ap-southeast-1',
'ap-southeast-2',
'ca-central-1',
'eu-central-1',
'eu-west-1',
'eu-west-2',
'eu-west-3',
'eu-north-1',
'eu-south-1',
'me-south-1',
'sa-east-1',
],
description: 'AWS region where the Lambda function will be deployed or is located.',
condition: {
field: 'operation',
value: ['fetch', 'create/update', 'deploy_endpoint'],
},
},
{
id: 'functionName',
title: 'Function Name',
type: 'short-input',
layout: 'full',
placeholder: 'Enter Lambda function name',
description:
'Name of the Lambda function. For fetch operations, this must be an existing function to understand its current state. For create/update, this will be the name of the new function or the existing function to update with any desired changes.',
condition: {
field: 'operation',
value: ['fetch', 'create/update', 'deploy_endpoint'],
},
},
{
id: 'endpointName',
title: 'Endpoint Name',
type: 'short-input',
layout: 'full',
placeholder: 'Enter API Gateway endpoint name',
description:
'Name for the API Gateway HTTP API endpoint. This will be used to create the API Gateway and will appear in the endpoint URL.',
condition: {
field: 'operation',
value: ['deploy_endpoint'],
},
},
{
id: 'runtime',
title: 'Runtime',
type: 'short-input',
layout: 'full',
placeholder: 'e.g., nodejs18.x, python3.11, java11',
description:
'Lambda runtime environment. Common values: nodejs18.x, python3.11, java11, go1.x, dotnet6, ruby2.7',
condition: {
field: 'operation',
value: ['create/update'],
},
},
{
id: 'handler',
title: 'Handler',
type: 'short-input',
layout: 'full',
placeholder: 'e.g., index.handler',
description:
'Function handler that Lambda calls to start execution. Format varies by runtime: index.handler (Node.js), lambda_function.lambda_handler (Python), etc.',
condition: {
field: 'operation',
value: ['create/update'],
},
},
{
id: 'timeout',
title: 'Timeout (seconds)',
type: 'short-input',
layout: 'half',
placeholder: 'Enter timeout in seconds (1-900)',
description: 'Function timeout in seconds. Must be between 1 and 900 seconds (15 minutes).',
condition: {
field: 'operation',
value: ['create/update'],
},
},
{
id: 'memorySize',
title: 'Memory (MB)',
type: 'short-input',
layout: 'half',
placeholder: 'Enter memory in MB (128-10240)',
description:
'Amount of memory allocated to the function in MB. Must be between 128 and 10240 MB.',
condition: {
field: 'operation',
value: ['create/update'],
},
},
{
id: 'code',
title: 'Function Code',
type: 'code',
layout: 'full',
language: 'json',
placeholder: '{\n "index.js": "exports.handler = async (event) => {...};"\n}',
description:
'Function code files as JSON object. Keys are file paths, values are file contents. For Node.js, typically include index.js with the handler function.',
condition: {
field: 'operation',
value: ['create/update'],
},
},
{
id: 'environmentVariables',
title: 'Environment Variables',
type: 'table',
layout: 'full',
columns: ['Key', 'Value'],
placeholder: 'Add environment variables as key-value pairs',
description:
'Environment variables that will be available to the Lambda function during execution.',
condition: {
field: 'operation',
value: ['create/update'],
},
},
{
id: 'tags',
title: 'Tags',
type: 'table',
layout: 'full',
columns: ['Key', 'Value'],
placeholder: 'Add tags as key-value pairs',
description: 'Tags to associate with the Lambda function for organization and cost tracking.',
condition: {
field: 'operation',
value: ['create/update'],
},
},
],
tools: {
access: [
'aws_lambda_deploy',
'aws_lambda_deploy_endpoint',
'aws_lambda_fetch',
'aws_lambda_get_prompts',
],
config: {
tool: (params: Record<string, any>) => {
const operation = String(params.operation || '').trim()
// Only map user-facing names; pass through tool IDs as-is
const operationMap: Record<string, string> = {
fetch: 'aws_lambda_fetch',
'create/update': 'aws_lambda_deploy',
deploy_endpoint: 'aws_lambda_deploy_endpoint',
getPrompts: 'aws_lambda_get_prompts',
}
if (operationMap[operation]) {
return operationMap[operation]
}
// If already a tool ID, return as-is
if (
operation === 'aws_lambda_fetch' ||
operation === 'aws_lambda_deploy' ||
operation === 'aws_lambda_deploy_endpoint' ||
operation === 'aws_lambda_get_prompts'
) {
return operation
}
// Default fallback
console.warn(`Unknown operation: "${operation}", defaulting to aws_lambda_fetch`)
return 'aws_lambda_fetch'
},
},
},
inputs: {
accessKeyId: { type: 'string', required: true },
secretAccessKey: { type: 'string', required: true },
region: { type: 'string', required: true },
role: { type: 'string', required: true },
operation: { type: 'string', required: true },
functionName: { type: 'string', required: true },
endpointName: { type: 'string', required: false },
handler: { type: 'string', required: false },
runtime: { type: 'string', required: false },
code: { type: 'json', required: false },
timeout: { type: 'number', required: false },
memorySize: { type: 'number', required: false },
environmentVariables: { type: 'json', required: false },
tags: { type: 'json', required: false },
},
outputs: {
functionArn: 'string',
functionName: 'string',
endpointName: 'any',
endpointUrl: 'any',
runtime: 'string',
region: 'string',
status: 'string',
lastModified: 'string',
codeSize: 'number',
description: 'string',
timeout: 'number',
memorySize: 'number',
environment: 'json',
tags: 'json',
codeFiles: 'json',
handler: 'string',
apiGatewayId: 'any',
stageName: 'any',
},
}

View File

@@ -76,13 +76,9 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
save_browser_data: { type: 'boolean', required: false }, save_browser_data: { type: 'boolean', required: false },
}, },
outputs: { outputs: {
response: { id: 'string',
type: { success: 'boolean',
id: 'string', output: 'any',
success: 'boolean', steps: 'json',
output: 'any',
steps: 'json',
},
},
}, },
} }

View File

@@ -50,10 +50,6 @@ Plain Text: Best for populating a table in free-form style.
data: { type: 'json', required: true }, data: { type: 'json', required: true },
}, },
outputs: { outputs: {
response: { data: 'any',
type: {
data: 'any',
},
},
}, },
} }

View File

@@ -37,13 +37,9 @@ export const ConditionBlock: BlockConfig<ConditionBlockOutput> = {
}, },
inputs: {}, inputs: {},
outputs: { outputs: {
response: { content: 'string',
type: { conditionResult: 'boolean',
content: 'string', selectedPath: 'json',
conditionResult: 'boolean', selectedConditionId: 'string',
selectedPath: 'json',
selectedConditionId: 'string',
},
},
}, },
} }

View File

@@ -109,14 +109,10 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
content: { type: 'string', required: false }, content: { type: 'string', required: false },
}, },
outputs: { outputs: {
response: { ts: 'string',
type: { pageId: 'string',
ts: 'string', content: 'string',
pageId: 'string', title: 'string',
content: 'string', success: 'boolean',
title: 'string',
success: 'boolean',
},
},
}, },
} }

View File

@@ -149,11 +149,7 @@ export const DiscordBlock: BlockConfig<DiscordResponse> = {
userId: { type: 'string', required: false }, userId: { type: 'string', required: false },
}, },
outputs: { outputs: {
response: { message: 'string',
type: { data: 'any',
message: 'string',
data: 'any',
},
},
}, },
} }

View File

@@ -39,11 +39,7 @@ export const ElevenLabsBlock: BlockConfig<ElevenLabsBlockResponse> = {
}, },
outputs: { outputs: {
response: { audioUrl: 'string',
type: {
audioUrl: 'string',
},
},
}, },
subBlocks: [ subBlocks: [

View File

@@ -307,25 +307,9 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
content: { type: 'string' as ParamType, required: true }, content: { type: 'string' as ParamType, required: true },
}, },
outputs: { outputs: {
response: { content: 'string',
type: { model: 'string',
content: 'string', tokens: 'any',
model: 'string', cost: 'any',
tokens: 'any', } as any,
cost: 'any',
},
dependsOn: {
subBlockId: 'metrics',
condition: {
whenEmpty: {
content: 'string',
model: 'string',
tokens: 'any',
cost: 'any',
},
whenFilled: 'json',
},
},
},
},
} }

View File

@@ -190,16 +190,12 @@ export const ExaBlock: BlockConfig<ExaResponse> = {
url: { type: 'string', required: false }, url: { type: 'string', required: false },
}, },
outputs: { outputs: {
response: { // Search output
type: { results: 'json',
// Search output // Find Similar Links output
results: 'json', similarLinks: 'json',
// Find Similar Links output // Answer output
similarLinks: 'json', answer: 'string',
// Answer output citations: 'json',
answer: 'string',
citations: 'json',
},
},
}, },
} }

View File

@@ -1,11 +1,12 @@
import { DocumentIcon } from '@/components/icons' import { DocumentIcon } from '@/components/icons'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import type { FileParserOutput } from '@/tools/file/types' import type { FileParserOutput } from '@/tools/file/types'
import type { BlockConfig, SubBlockConfig, SubBlockLayout, SubBlockType } from '../types' import type { BlockConfig, SubBlockConfig, SubBlockLayout, SubBlockType } from '../types'
const logger = createLogger('FileBlock') const logger = createLogger('FileBlock')
const shouldEnableURLInput = process.env.NODE_ENV === 'production' const shouldEnableURLInput = env.NODE_ENV === 'production'
const inputMethodBlock: SubBlockConfig = { const inputMethodBlock: SubBlockConfig = {
id: 'inputMethod', id: 'inputMethod',
@@ -130,11 +131,7 @@ export const FileBlock: BlockConfig<FileParserOutput> = {
file: { type: 'json', required: false }, file: { type: 'json', required: false },
}, },
outputs: { outputs: {
response: { files: 'json',
type: { combinedContent: 'string',
files: 'json',
combinedContent: 'string',
},
},
}, },
} }

View File

@@ -90,16 +90,12 @@ export const FirecrawlBlock: BlockConfig<FirecrawlResponse> = {
scrapeOptions: { type: 'json', required: false }, scrapeOptions: { type: 'json', required: false },
}, },
outputs: { outputs: {
response: { // Scrape output
type: { markdown: 'string',
// Scrape output html: 'any',
markdown: 'string', metadata: 'json',
html: 'any', // Search output
metadata: 'json', data: 'json',
// Search output warning: 'any',
data: 'json',
warning: 'any',
},
},
}, },
} }

Some files were not shown because too many files have changed in this diff Show More