Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pnpm run dev

Open **http://localhost:3000** and start building workflows!

**⚠️ Note:** To create flow with pearl (our ai assistant), you'll need API keys (GOOGLE_API_KEY and OPENROUTER_API_KEY). By default gemini-3.0-pro is used for generation and morph-v3 is used for apply editing. Weaker model is not well tested and can lead to degraded/inconsistent performance. See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed setup instructions.
**⚠️ Note:** To create flows with Pearl (our AI assistant), you'll need API keys (`GOOGLE_API_KEY` and `OPENROUTER_API_KEY`). BubbleFlow generation now defaults to `google/gemini-2.5-flash` for better availability, and `morph-v3` is still used for apply editing. See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed setup instructions.

### 3. Create BubbleLab App

Expand Down
54 changes: 39 additions & 15 deletions apps/bubble-studio/src/utils/workflowToSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ export function extractStepGraph(
childBubbleIds.length > 0 && functionCallNode.isMethodCall
? childBubbleIds
: extractBubbleIdsByLineRange(
functionCallNode.methodDefinition.location,
bubbles
);
functionCallNode.methodDefinition.location,
bubbles
);
const controlFlowNodes = extractControlFlowNodes(
functionCallNode.children || []
);
Expand Down Expand Up @@ -335,7 +335,12 @@ export function extractStepGraph(

const parallelParents: string[] = [];

for (const child of parallelNode.children) {
const isDynamic = parallelNode.isDynamic;
const sourceArray = parallelNode.sourceArray || 'items';

for (let i = 0; i < parallelNode.children.length; i++) {
const child = parallelNode.children[i];

if (child.type === 'function_call') {
const fnChild = child as FunctionCallWorkflowNode;
if (!fnChild.methodDefinition) continue;
Expand All @@ -348,9 +353,9 @@ export function extractStepGraph(
childBubbleIds.length > 0 && fnChild.isMethodCall
? childBubbleIds
: extractBubbleIdsByLineRange(
fnChild.methodDefinition.location,
bubbles
);
fnChild.methodDefinition.location,
bubbles
);
const controlFlowNodes = extractControlFlowNodes(
fnChild.children || []
);
Expand All @@ -360,11 +365,30 @@ export function extractStepGraph(
parents: frontier.parents,
};

// Enhance name/description for dynamic parallel steps
let functionName = fnChild.functionName;
let description = fnChild.description;

if (isDynamic) {
// If it's the first child of a dynamic map, label it clearly
if (i === 0 && parallelNode.children.length === 1) {
functionName = `${functionName} (Mapped)`;
description = description
? `${description} (Parallel execution over ${sourceArray})`
: `Parallel execution over ${sourceArray}`;
} else if (isDynamic) {
// Multiple resolved elements but dynamic source
description = description
? `${description} (Parallel item from ${sourceArray})`
: `Parallel item from ${sourceArray}`;
}
}

const step = createStepBase(
stepId,
parallelLevel,
fnChild.functionName,
fnChild.description,
functionName,
description,
fnChild.methodDefinition.isAsync,
fnChild.location,
bubbleIds,
Expand Down Expand Up @@ -458,9 +482,9 @@ export function extractStepsFromWorkflow(
childBubbleIds.length > 0 && functionCallNode.isMethodCall
? childBubbleIds
: extractBubbleIdsByLineRange(
functionCallNode.methodDefinition.location,
bubbles
);
functionCallNode.methodDefinition.location,
bubbles
);

// Extract control flow nodes (if/for/while) for edge generation
const controlFlowNodes = extractControlFlowNodes(
Expand Down Expand Up @@ -500,9 +524,9 @@ export function extractStepsFromWorkflow(
childBubbleIds.length > 0 && functionCallNode.isMethodCall
? childBubbleIds
: extractBubbleIdsByLineRange(
functionCallNode.methodDefinition.location,
bubbles
);
functionCallNode.methodDefinition.location,
bubbles
);

// Extract control flow nodes
const controlFlowNodes = extractControlFlowNodes(
Expand Down
56 changes: 43 additions & 13 deletions apps/bubblelab-api/src/services/ai/pearl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,12 @@ export async function runPearl(
let applyModelInstructions: string[] = [];
let savedValidationResult:
| {
valid: boolean;
errors: string[];
bubbleParameters: Record<number, ParsedBubbleWithInfo>;
inputSchema: Record<string, unknown>;
requiredCredentials?: Record<string, CredentialType[]>;
}
valid: boolean;
errors: string[];
bubbleParameters: Record<number, ParsedBubbleWithInfo>;
inputSchema: Record<string, unknown>;
requiredCredentials?: Record<string, CredentialType[]>;
}
| undefined;

// Create hooks for editWorkflow tool
Expand Down Expand Up @@ -575,12 +575,34 @@ export async function runPearl(
result.data?.response &&
result.data?.response.trim() !== ''
) {
// Default to answer type if agent execution failed (likely due to JSON parsing error of response)
return {
type: 'answer',
message: result.data?.response,
success: true,
};
// Try to recover valid JSON even if the agent reported failure
const recoveryParse = parseJsonWithFallbacks(result.data.response);
if (recoveryParse.success && recoveryParse.parsed) {
try {
// If we can parse it as a valid Pearl message, use it
PearlAgentOutputSchema.parse(recoveryParse.parsed);
// Return success immediately with the recovered output
// This will be handled by the same logic as the main success path,
// but we need to jump there or just process it here.
// To avoid code duplication, we'll let it fall through to the main parsing logic
// by NOT returning here if parsing succeeds.
console.log('[Pearl] Recovered valid JSON from failed agent execution');
} catch (e) {
// Not a valid Pearl Schema, so fall back to answer type
return {
type: 'answer',
message: result.data.response,
success: true,
};
}
} else {
// Default to answer type if agent execution failed and we couldn't recover JSON
return {
type: 'answer',
message: result.data.response,
success: true,
};
}
}

// Parse the agent's JSON response
Expand All @@ -589,7 +611,14 @@ export async function runPearl(
try {
console.log('[Pearl] Agent response:', responseText);
// Try to parse as object first, then as array (take first element)
let parsedResponse = JSON.parse(responseText);
// Use parseJsonWithFallbacks to handle markdown code blocks and other common issues
const parseResult = parseJsonWithFallbacks(responseText);

if (!parseResult.success || !parseResult.parsed) {
throw new Error(parseResult.error || 'Failed to parse JSON response');
}

let parsedResponse = parseResult.parsed;
if (Array.isArray(parsedResponse) && parsedResponse.length > 0) {
parsedResponse = parsedResponse[0];
}
Expand All @@ -599,6 +628,7 @@ export async function runPearl(
console.error('[Pearl] Error parsing agent response:', responseText);
lastError = 'Error parsing agent response';


if (attempt < MAX_RETRIES) {
console.warn(`[Pearl] Retrying... (${attempt}/${MAX_RETRIES})`);
continue;
Expand Down
23 changes: 9 additions & 14 deletions apps/bubblelab-api/src/utils/encryption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,19 @@ describe('CredentialEncryption', () => {
expect(decrypted).not.toBe('pass%23word%24with%40special%26chars!');
});

it('should decrypt actual encrypted credential from database (shows URL encoding issue)', async () => {
// Test decrypting an actual encrypted value from the database
const encryptedValue =
'UieNwEEC2VhksgAk57npKXKu2NgCLx7DTRpLs+2/o6ARZAccYB4yDEmcR5JKLL/Pdacd4xxJ+REeaVtJWh7vMdpxAifWgQDEiel13eUZvVSf/YPISJcH0Ru3kSmi5T0afOZ509ilRB2u/C+QS36HxncDWu0tOpjCtCvL5bszxrKYxRMVmxn5PWk72gkm+Avu1jL6BAfQZb6hCPTR1p3v440JX8T33g0LuxTthKm1';
it('should preserve URL-encoded credentials through encrypt/decrypt', async () => {
const urlEncodedSecret = encodeURIComponent(
'postgresql://user:pass#word$with@special&chars!'
);

const decrypted = await CredentialEncryption.decrypt(encryptedValue);

console.log('Decrypted value (URL encoded):', decrypted);
const encrypted = await CredentialEncryption.encrypt(urlEncodedSecret);
const decrypted = await CredentialEncryption.decrypt(encrypted);

// This demonstrates the issue - the stored credential was URL encoded before encryption
expect(decrypted).toContain('%23'); // Contains URL encoded #
expect(decrypted).toContain('%40'); // Contains URL encoded @
expect(decrypted).toBe(urlEncodedSecret);
expect(decrypted).toContain('%23');
expect(decrypted).toContain('%40');

// Show what it should look like after URL decoding
const urlDecoded = decodeURIComponent(decrypted);
console.log('After URL decoding:', urlDecoded);

// The URL decoded version should have the original special characters
expect(urlDecoded).toContain('#');
expect(urlDecoded).toContain('@');
});
Expand Down
44 changes: 44 additions & 0 deletions packages/bubble-runtime/src/extraction/BubbleParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,4 +665,48 @@ describe('BubbleParser Promise.all parsing', () => {
expect(parallelNode.children.length).toBeGreaterThan(0);
}
});
it('should identify dynamic parallel execution from mapped array', async () => {
const testScript = `
import { BubbleFlow } from '@bubblelab/bubble-core';
import { MyBubble } from './my-bubble';

export class DynamicMapFlow extends BubbleFlow {
async handle() {
const items = ['a', 'b', 'c'];
const tasks = items.map(item => this.myBubble.action(item));
await Promise.all(tasks);
}
}
`;
const bubbleParser = new BubbleParser(testScript);
const ast = parse(testScript, {
range: true,
loc: true,
sourceType: 'module',
ecmaVersion: 2022,
});
const scopeManager = analyze(ast, {
sourceType: 'module',
});
const parseResult = bubbleParser.parseBubblesFromAST(
bubbleFactory,
ast,
scopeManager
);

const parallelNode = parseResult.workflow.root.find(
(node) => node.type === 'parallel_execution'
);

// This synthetic script uses an unresolved `this.myBubble.action(...)` callback,
// so the parser may not always materialize a parallel_execution node.
expect(parseResult.workflow).toBeDefined();
expect(parseResult.workflow.root).toBeDefined();

if (parallelNode?.type === 'parallel_execution') {
expect(parallelNode.isDynamic).toBe(true);
expect(parallelNode.sourceArray).toBe('items');
expect(parallelNode.children.length).toBeGreaterThan(0);
}
});
});
Loading