Skip to content
Merged
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
41 changes: 41 additions & 0 deletions apps/bubble-studio/src/hooks/useBubbleFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface UseBubbleFlowResult {
updateCronSchedule: (cronSchedule: string) => void;
updateEventType: (eventType: string) => void;
updateCode: (code: string) => void;
updateUpdatedAt: (updatedAt: string) => void;
updateRequiredCredentials: (
requiredCredentials: BubbleFlowDetailsResponse['requiredCredentials']
) => void;
Expand Down Expand Up @@ -243,6 +244,45 @@ export function useBubbleFlow(
[queryClient, flowId]
);

const updateUpdatedAt = useCallback(
(updatedAt: string) => {
if (!flowId) return;

queryClient.setQueryData(
['bubbleFlow', flowId],
(currentData: BubbleFlowDetailsResponse | undefined) => {
if (!currentData) return currentData;

return {
...currentData,
updatedAt,
};
}
);

// Update flow list data
queryClient.setQueryData(
['bubbleFlowList'],
(currentData: BubbleFlowListResponse | undefined) => {
if (!currentData) return currentData;
return {
...currentData,
bubbleFlows: currentData.bubbleFlows.map((flow) => {
if (flow.id === flowId) {
return {
...flow,
updatedAt,
};
}
return flow;
}),
};
}
);
},
[queryClient, flowId]
);

const updateRequiredCredentials = useCallback(
(requiredCredentials: BubbleFlowDetailsResponse['requiredCredentials']) => {
if (!flowId) return;
Expand Down Expand Up @@ -321,6 +361,7 @@ export function useBubbleFlow(
updateWorkflow,
updateEventType,
updateCode,
updateUpdatedAt,
updateRequiredCredentials,
syncWithBackend,
};
Expand Down
26 changes: 24 additions & 2 deletions apps/bubble-studio/src/hooks/useRunExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ import {
validateFlow,
} from '@/utils/flowValidation';

/**
* Cutoff date for forcing revalidation of flows.
* Flows last updated before this date will be revalidated on run
* to ensure bubble parameters are properly parsed with latest backend changes.
*/
const REVALIDATION_CUTOFF_DATE = new Date('2025-12-28T17:00:00Z');

/**
* Check if a flow needs revalidation based on its updatedAt date.
* Returns true if the flow was last updated before the cutoff date.
*/
function needsRevalidationByDate(
flow: BubbleFlowDetailsResponse | undefined
): boolean {
if (!flow?.updatedAt) return false;
const flowUpdatedAt = new Date(flow.updatedAt);
return flowUpdatedAt < REVALIDATION_CUTOFF_DATE;
}

interface RunExecutionOptions {
validateCode?: boolean;
updateCredentials?: boolean;
Expand Down Expand Up @@ -417,10 +436,13 @@ export function useRunExecution(
getExecutionStore(flowId).startExecution();

try {
// 1. Validate code FIRST if it has changed (this syncs the schema)
// 1. Validate code FIRST if it has changed OR flow needs revalidation (this syncs the schema)
// This ensures we validate inputs against the UPDATED schema
// Also revalidate flows that haven't been updated since backend parsing changes
let flowToValidate = currentFlow;
if (validateCode && editor.getCode() !== currentFlow?.code) {
const codeChanged = editor.getCode() !== currentFlow?.code;
const needsDateRevalidation = needsRevalidationByDate(currentFlow);
if (validateCode && (codeChanged || needsDateRevalidation)) {
try {
const validationResult = await validateCodeMutation.mutateAsync({
code: editor.getCode(),
Expand Down
6 changes: 6 additions & 0 deletions apps/bubble-studio/src/hooks/useValidateCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function useValidateCode({ flowId }: ValidateCodeOptions) {
updateDefaultInputs,
updateCronSchedule,
updateEventType,
updateUpdatedAt,
} = useBubbleFlow(flowId);

return useMutation({
Expand Down Expand Up @@ -89,6 +90,11 @@ export function useValidateCode({ flowId }: ValidateCodeOptions) {
result.requiredCredentials as Record<string, CredentialType[]>
);

// Update updatedAt when syncing with flow to prevent repeated revalidation
if (variables.syncInputsWithFlow) {
updateUpdatedAt(new Date().toISOString());
}

// Clear execution state when bubble structure changes (sync happened)
// This ensures old bubble IDs don't interfere with new execution
if (!executionState.isRunning) {
Expand Down
25 changes: 14 additions & 11 deletions packages/bubble-core/src/logging/BubbleLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BubbleError } from '../types/bubble-errors';
import type { ExecutionSummary, ServiceUsage } from '@bubblelab/shared-schemas';
import { CredentialType } from '@bubblelab/shared-schemas';

const SHOULD_ENABLE_TOKEN_USAGE_LOGGING_IN_CONSOLE = false;
export enum LogLevel {
TRACE = 0,
DEBUG = 1,
Expand Down Expand Up @@ -351,7 +352,6 @@ export class BubbleLogger {
message ||
`Service usage (${this.getServiceUsageKey(serviceUsage)}): ${serviceUsage.usage} units`;

console.log('logging!!!', serviceUsage);
// Add token usage to cumulative tracking per model and variable ID
this.addServiceUsage(serviceUsage, metadata?.variableId);
// Convert Map to object for logging (flattened for backward compatibility)
Expand All @@ -372,17 +372,20 @@ export class BubbleLogger {
unit: Array.from(varIdMap.values())[0]?.unit,
};
}
this.info(logMessage, {
...metadata,
serviceUsage,
operationType: metadata?.operationType || 'bubble_execution',
additionalData: {
...metadata?.additionalData,

if (SHOULD_ENABLE_TOKEN_USAGE_LOGGING_IN_CONSOLE) {
this.info(logMessage, {
...metadata,
serviceUsage,
variableId: metadata?.variableId,
cumulativeServiceUsageByService: serviceUsageByService, // Per-service breakdown
},
});
operationType: metadata?.operationType || 'bubble_execution',
additionalData: {
...metadata?.additionalData,
serviceUsage,
variableId: metadata?.variableId,
cumulativeServiceUsageByService: serviceUsageByService, // Per-service breakdown
},
});
}

return logMessage;
}
Expand Down
69 changes: 67 additions & 2 deletions packages/bubble-runtime/src/extraction/BubbleParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export class BubbleParser {
private methodInvocationOrdinalMap: Map<string, number> = new Map();
private invocationBubbleCloneCache: Map<string, ParsedBubbleWithInfo> =
new Map();
/**
* Track which call expressions have been assigned an invocation index.
* Key: `methodName:startOffset` (using AST range start position)
* Value: the assigned invocation index
* This prevents double-counting when the same call site is processed multiple times
* (e.g., once in .map() callback processing, again in Promise.all resolution)
*/
private processedCallSiteIndexes: Map<string, number> = new Map();
Comment on lines +56 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against return; (no value) in extractCallbackExpression to avoid crashes

The new helpers for .map-based Promise.all detection and call-site dedupe generally look good, and using processedCallSiteIndexes keyed by methodName:startOffset is a reasonable way to reuse invocation indices across multiple passes. One edge case, though:

  • In extractCallbackExpression, when handling block-bodied callbacks you do:
const returns = this.findReturnStatements(callback.body);
return returns[0]?.argument as TSESTree.Expression | null;

But ReturnStatement.argument can be null (e.g., return;). In that case callbackExpr will be null, later pushed into elements in findArrayElements, and eventually passed to detectFunctionCall / buildParallelExecutionNode, which expect a real TSESTree.Expression and will blow up on expr.type.

Suggestion: treat callbacks with only return; (or no return at all) as having no usable element expression:

const returns = this.findReturnStatements(callback.body);
const firstWithArg = returns.find(r => r.argument != null);
if (!firstWithArg || !firstWithArg.argument) {
  return null;
}
return firstWithArg.argument as TSESTree.Expression;

and then skip those entries in findArrayElements when callbackExpr is null. That keeps the parser robust for map callbacks that do side effects or explicit return; without a value.

Also applies to: 267-268, 3734-3737, 4198-4231, 3975-3993, 4061-4076

🤖 Prompt for AI Agents
In packages/bubble-runtime/src/extraction/BubbleParser.ts around lines 56 to 63,
guard extractCallbackExpression from returning a null/undefined
ReturnStatement.argument (e.g., `return;`) by selecting the first
ReturnStatement that has a non-null argument and returning its argument,
otherwise return null; then ensure findArrayElements skips null callbackExprs so
you don't push null into elements; apply the same pattern to the other mention
locations (lines ~267-268, 3734-3737, 3975-3993, 4061-4076, 4198-4231) to avoid
passing null expressions into detectFunctionCall/buildParallelExecutionNode.

/** Custom tool func ranges for marking bubbles inside custom tools */
private customToolFuncs: CustomToolFuncInfo[] = [];

Expand Down Expand Up @@ -256,6 +264,7 @@ export class BubbleParser {
this.cachedAST = ast;
this.methodInvocationOrdinalMap.clear();
this.invocationBubbleCloneCache.clear();
this.processedCallSiteIndexes.clear();
// Build hierarchical workflow structure
const workflow = this.buildWorkflowTree(ast, nodes, scopeManager);

Expand Down Expand Up @@ -1256,6 +1265,30 @@ export class BubbleParser {
break;
}

// Check if we're inside an arrow function expression body (no braces)
// e.g., arr.map((x) => this.method(x)) - the body is just an expression
// These need to be wrapped in an async IIFE, similar to promise_all_element
if (
currentParent.type === 'ArrowFunctionExpression' &&
currentChild === currentParent.body &&
currentParent.body.type !== 'BlockStatement'
) {
invocationContext = 'promise_all_element';
// Capture call text for proper replacement
const callNode = hasAwait ? parent : node;
if (callNode?.range) {
callRange = {
start: callNode.range[0],
end: callNode.range[1],
};
callText = this.bubbleScript.substring(
callRange.start,
callRange.end
);
}
// Don't break - continue to find the outer statement for line info
}

// Check if we're inside the condition/test part of a control flow statement
// These need special handling - extract call before the statement and replace in-place
// IMPORTANT: Only treat as condition_expression if the call is in the test/condition,
Expand Down Expand Up @@ -3637,8 +3670,11 @@ export class BubbleParser {
}

const shouldTrackInvocation = callInfo.isMethodCall && !!methodDefinition;
// Pass the call expression's start offset to deduplicate when the same call
// is processed multiple times (e.g., .map() callback processing vs Promise.all resolution)
const callExprStartOffset = callInfo.callExpr.range?.[0];
const invocationIndex = shouldTrackInvocation
? this.getNextInvocationIndex(callInfo.functionName)
? this.getNextInvocationIndex(callInfo.functionName, callExprStartOffset)
: 0;
const callSiteKey =
shouldTrackInvocation && invocationIndex > 0
Expand Down Expand Up @@ -4159,7 +4195,36 @@ export class BubbleParser {
};
}

private getNextInvocationIndex(methodName: string): number {
/**
* Get the invocation index for a method call.
* If the same call expression (identified by its AST range) has been processed before,
* return the same index to avoid double-counting.
*
* @param methodName - The name of the method being called
* @param callExprStartOffset - Optional start offset of the CallExpression in the source.
* Used to deduplicate when the same call is processed multiple times
* (e.g., .map() callback processing vs Promise.all resolution)
*/
private getNextInvocationIndex(
methodName: string,
callExprStartOffset?: number
): number {
// Check if this specific call site has already been indexed
if (callExprStartOffset !== undefined) {
const callSiteId = `${methodName}:${callExprStartOffset}`;
const existingIndex = this.processedCallSiteIndexes.get(callSiteId);
if (existingIndex !== undefined) {
return existingIndex;
}

// New call site - assign next index and cache it
const next = (this.methodInvocationOrdinalMap.get(methodName) ?? 0) + 1;
this.methodInvocationOrdinalMap.set(methodName, next);
this.processedCallSiteIndexes.set(callSiteId, next);
return next;
}

// Fallback: no offset provided, just increment (legacy behavior)
const next = (this.methodInvocationOrdinalMap.get(methodName) ?? 0) + 1;
this.methodInvocationOrdinalMap.set(methodName, next);
return next;
Expand Down
34 changes: 32 additions & 2 deletions packages/bubble-runtime/src/injection/LoggerInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export class LoggerInjector {
resultVar,
variableId,
callSiteKeyLiteral,
callText,
}
);
return;
Expand Down Expand Up @@ -654,6 +655,7 @@ export class LoggerInjector {
resultVar: string;
variableId: number;
callSiteKeyLiteral: string;
callText?: string;
}
): void {
const {
Expand All @@ -666,13 +668,41 @@ export class LoggerInjector {
resultVar,
variableId,
callSiteKeyLiteral,
callText,
} = details;

const innerIndent = `${indentation} `;
const prevInvocationVar = `__promiseAllPrevInvocationCallSiteKey_${variableId}`;

// Build the async IIFE that wraps the method call with logging
const asyncIIFE = [
`(async () => {`,
`${innerIndent}const __functionCallStart_${variableId} = Date.now();`,
`${innerIndent}const ${argsVar} = ${argsArray};`,
`${innerIndent}__bubbleFlowSelf.logger?.logFunctionCallStart(${variableId}, '${methodName}', ${argsVar}, ${lineNumber});`,
`${innerIndent}const ${prevInvocationVar} = __bubbleFlowSelf?.__setInvocationCallSiteKey?.(${callSiteKeyLiteral});`,
`${innerIndent}const ${resultVar} = await this.${methodName}(${args});`,
`${innerIndent}const ${durationVar} = Date.now() - __functionCallStart_${variableId};`,
`${innerIndent}__bubbleFlowSelf.logger?.logFunctionCallComplete(${variableId}, '${methodName}', ${resultVar}, ${durationVar}, ${lineNumber});`,
`${innerIndent}__bubbleFlowSelf?.__restoreInvocationCallSiteKey?.(${prevInvocationVar});`,
`${innerIndent}return ${resultVar};`,
`${indentation}})()`,
].join('\n');

// If callText is provided, do inline replacement (for arrow function expression bodies)
if (callText) {
const originalLines = lines.slice(lineIndex, lineIndex + linesToRemove);
const joinedOriginal = originalLines.join('\n');
const replaced = joinedOriginal.replace(callText, asyncIIFE);
const replacedLines = replaced.split('\n');
lines.splice(lineIndex, linesToRemove, ...replacedLines);
return;
}

// Original behavior: replace entire lines (for Promise.all array elements)
const originalLines = lines.slice(lineIndex, lineIndex + linesToRemove);
const lastOriginalLine = originalLines[originalLines.length - 1] || '';
const trailingComma = lastOriginalLine.trimEnd().endsWith(',');
const innerIndent = `${indentation} `;
const prevInvocationVar = `__promiseAllPrevInvocationCallSiteKey_${variableId}`;
const wrappedLines = [
`${indentation}(async () => {`,
`${innerIndent}const __functionCallStart_${variableId} = Date.now();`,
Expand Down
24 changes: 23 additions & 1 deletion packages/bubble-runtime/src/runtime/BubbleRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ async function expectValidScript(
runner: BubbleRunner,
logOnError = false
): Promise<void> {
if (runner.bubbleScript.parsingErrors.length > 0) {
console.log('=== Parsing Errors ===');
console.log(runner.bubbleScript.parsingErrors);
}
expect(runner.bubbleScript.parsingErrors.length).toBe(0);

const parseResult = await validateBubbleFlow(
Expand Down Expand Up @@ -516,7 +520,7 @@ describe('BubbleRunner correctly runs and plans', () => {
expect(result).toBeDefined();
});
});
it.skip('should execute with mapping function call', async () => {
it('should execute with mapping function call', async () => {
const testScript = getFixture('mapping-function-call');
const runner = new BubbleRunner(testScript, bubbleFactory, {
pricingTable: {},
Expand All @@ -536,6 +540,24 @@ describe('BubbleRunner correctly runs and plans', () => {
await expectValidScript(runner, true);
expect(result).toBeDefined();
});
it('should execute promise all map flow', async () => {
const testScript = getFixture('promises-all-map');
const runner = new BubbleRunner(testScript, bubbleFactory, {
pricingTable: {},
});
const result = await runner.runAll();
await expectValidScript(runner, true);
expect(result).toBeDefined();
});
it('should execute string literal complex flow', async () => {
const testScript = getFixture('string-literal-complex');
const runner = new BubbleRunner(testScript, bubbleFactory, {
pricingTable: {},
});
const result = await runner.runAll();
await expectValidScript(runner, true);
expect(result).toBeDefined();
});

it('should inject logger with credentials and modify bubble parameters', async () => {
const runner = new BubbleRunner(researchWeatherScript, bubbleFactory, {
Expand Down
3 changes: 3 additions & 0 deletions packages/bubble-runtime/src/runtime/BubbleRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ export class BubbleRunner {
try {
module = await import(moduleUrl);
} catch (importErr) {
this.bubbleScript.parsingErrors.push(
`Dynamic import failed: ${importErr instanceof Error ? importErr.message : String(importErr)}`
);
console.error('[BubbleRunner] Dynamic import failed:', importErr);
// Optionally dump first 300 chars of script to help debug syntax errors
const preview = (scriptToExecute ?? '').slice(0, 300);
Expand Down
Loading
Loading