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
181 changes: 171 additions & 10 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import {
import { applyFlutterFlowHeader } from "./src/flutterFlowHeader.js";
import { buildBundleDeployPlan } from "./src/bundleDeployPlanner.js";
import {
buildFinishPushFileMap,
excludeProvisionedCodeFiles,
findMissingCodeFiles,
isDeployFinishable,
partitionProvisionedCodeFiles,
} from "./src/flutterFlowCodeFileProvisioning.js";
import { buildReviewPresentation } from "./src/reviewPresentation.js";
Expand Down Expand Up @@ -3219,7 +3221,8 @@ async function reconcileUnconfirmedProvision(apiClient, attempted, original) {
message =
`Checked FlutterFlow just now: all ${landed.length} custom class(es) are in the project (${names(landed)}). ` +
"The final step, pushing your pubspec.yaml dependencies, did not run. " +
"Deploy again to finish it - classes already there are skipped, not duplicated.";
"Finish deploy to push the dependencies without re-writing the classes, " +
"or deploy again - classes already there are skipped, not duplicated.";
} else if (landed.length === 0) {
message =
`Checked FlutterFlow just now: none of the ${notLanded.length} custom class(es) are in the project yet (${names(notLanded)}). ` +
Expand All @@ -3231,7 +3234,11 @@ async function reconcileUnconfirmedProvision(apiClient, attempted, original) {
`not there yet: ${names(notLanded)}. Wait a minute, then deploy again - ` +
"classes already there are skipped, and the rest are written.";
}
return new UnconfirmedDeployError(message);
const error = new UnconfirmedDeployError(message);
// The reconcile outcome decides whether the terminal can offer "Finish
// deploy": when every class landed, only the pubspec push remains.
error.reconcilePartition = { landed, notLanded };
return error;
}

async function provisionMissingCodeFiles(
Expand Down Expand Up @@ -3422,11 +3429,28 @@ async function provisionMissingCodeFiles(
},
() => {},
);
throw await reconcileUnconfirmedProvision(
const reconciled = await reconcileUnconfirmedProvision(
apiClient,
missingCodeFiles,
error,
);
// When every attempted class landed, offer "Finish deploy": completing
// the deploy only needs step 3 (the pubspec push). Carry what that push
// needs — the deploy-time credentials, the full file map, the already
// merged yaml, and the landed entry list — so the terminal action can
// push without re-provisioning the classes.
if (isDeployFinishable(reconciled.reconcilePartition)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Failed reconciliation falsely marks deploy failed

When the project re-read fails, isDeployFinishable receives no partition and throws. The original unconfirmed outcome becomes a failed deploy, although classes can still land.

Suggested change
if (isDeployFinishable(reconciled.reconcilePartition)) {
if (reconciled.reconcilePartition && isDeployFinishable(reconciled.reconcilePartition)) {

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

reconciled.finishDeploy = {
apiKey: apiClient.apiKey,
projectId: apiClient.projectId,
endpoint: apiClient.baseUrl,
branchName: apiClient.branchName,
fileMap,
serializedYaml: pubspecYaml,
landed: reconciled.reconcilePartition.landed,
};
}
throw reconciled;
}
throw error;
}
Expand Down Expand Up @@ -4542,6 +4566,9 @@ async function executeCommit(code, options = {}) {
targetIdentity,
state: commitState.currentState,
elapsedTime: commitState.getElapsedTime(),
...(error.finishDeploy
? { finishDeploy: mergeFinishDeployContext(error.finishDeploy, targetIdentity, commitState) }
: {}),
};
}

Expand Down Expand Up @@ -4749,6 +4776,9 @@ async function executeBundleCommit(bundlePlan, options = {}) {
targetIdentity,
state: commitState.currentState,
elapsedTime: commitState.getElapsedTime(),
...(error.finishDeploy
? { finishDeploy: mergeFinishDeployContext(error.finishDeploy, targetIdentity, commitState) }
: {}),
};
}

Expand Down Expand Up @@ -7804,17 +7834,147 @@ function showCommitPartialModal(result) {
* in-flight write is never cancelled.
*/
function showCommitUnconfirmedModal(result) {
let heading = "Deploy outcome not yet known";
let title =
"This browser stopped waiting before the FlutterFlow server reported a result.";
let guidance =
"The deploy may still be finishing on the server — it was <strong>not cancelled</strong>. " +
"Open your FlutterFlow project and confirm whether the class landed before retrying, so you do not " +
"push a duplicate or build on top of an unknown state.";

// When a reconcile proved every class this deploy was writing landed, only
// the pubspec push is missing. Offer it here instead of a blind full
// re-deploy, which would re-run the slower class write for nothing.
if (result.finishDeploy) {
pendingFinishDeploy = result.finishDeploy;
heading = "Deploy almost complete";
title =
"Your custom class(es) are in FlutterFlow — only the dependency push remains.";
guidance =
"Every class this deploy was writing is already in your project. The one step that did not run " +
"was pushing your pubspec.yaml dependencies, which the project needs to build. " +
"<button id=\"terminal-finish-deploy\" onclick=\"window.finishProvisionedDeploy()\" " +
"class=\"mt-3 px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors\">" +
"Finish deploy</button>";
}

populateCommitTerminalModal(result, {
heading: "Deploy outcome not yet known",
title:
"This browser stopped waiting before the FlutterFlow server reported a result.",
guidance:
"The deploy may still be finishing on the server — it was <strong>not cancelled</strong>. " +
"Open your FlutterFlow project and confirm whether the class landed before retrying, so you do not " +
"push a duplicate or build on top of an unknown state.",
heading,
title,
guidance,
});
}

// The "Finish deploy" action completes an interrupted deploy whose classes
// already landed. Holds the push context (credentials, file map, merged yaml,
// landed entries) plus the terminal template so the push renders like any
// deploy. Set by showCommitUnconfirmedModal, read by finishProvisionedDeploy.
let pendingFinishDeploy = null;

/**
* Merges the reconcile's finish-push context — built in
* `provisionMissingCodeFiles` where the api client, file map, and merged yaml
* are in scope — with the current deploy's terminal identity, so a finished
* deploy renders the same truthful terminal a normal deploy would.
* @param {Object} context - The finishDeploy push context on the reconcile error
* @param {Object} targetIdentity - The interrupted deploy's target identity
* @param {Object} commitState - Holds the elapsed-time for the terminal render
* @returns {Object} The merged finishDeploy payload
*/
function mergeFinishDeployContext(context, targetIdentity, commitState) {
return {
...context,
result: {
targetIdentity,
metadata: {
artifactType: targetIdentity?.artifactType,
artifactName: targetIdentity?.artifactName,
fileName: targetIdentity?.fileName,
projectId: targetIdentity?.projectId,
},
message:
`Deploy finished: ${targetIdentity?.artifactName || "custom classes"} and ` +
"their dependencies are now in FlutterFlow.",
addedDependencies: [],
unverified: [],
approximate: [],
warnings: [],
elapsedTime: commitState.getElapsedTime(),
},
};
}

/**
* Completes an interrupted deploy whose classes already landed by running only
* step 3 — the pubspec push. The landed classes are excluded from the sync map
* (they are already in the project), so this never re-provisions them, and the
* push reuses the same file-map/zip/push machinery as a normal deploy.
*/
async function finishProvisionedDeploy() {
const finish = pendingFinishDeploy;
if (!finish) return;
if (deployInFlight) return;
pendingFinishDeploy = null;

const apiClient = new FlutterFlowApiClient(
finish.apiKey,
finish.projectId,
finish.branchName,
finish.endpoint,
);

closeCommitTerminalModal();
setDeployBusy(true);
showCommitProgress();

try {
commitProgress.set("package");
const finishFileMap = buildFinishPushFileMap(
finish.fileMap,
finish.landed,
finish.serializedYaml,
);
const syncMetadata = await buildApiSyncMetadata(finishFileMap, new Map());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Existing bundle artifacts lose update metadata

For bundles with existing actions, widgets, or functions, buildApiSyncMetadata treats retained files as new. The empty remote map drops original checksums and marks existing functions for addition, so the finish push can fail.

Learn more

The finish map removes only classes that the runner attempted to create. Other bundle entries can already exist in FlutterFlow. buildFlutterFlowSyncMetadata consults remote files to attach original_checksum to updates and to avoid adding functions that already exist. Unlike the normal bundle push, Finish supplies an empty map, even when these remote entries are present.

Example: A bundle has a new model.dart and an existing read_tag.dart. After model.dart lands, Finish retains read_tag.dart but omits its original checksum; FlutterFlow receives a create-shaped update instead of an update.

Recommended fix: Fetch fresh remote files at finish time and pass them to buildApiSyncMetadata; reconcile their contents and handle concurrent edits before constructing the push.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

const zippedCustomCode = await createZipFromFileMap(finishFileMap);

const pushRequest = {
project_id: finish.projectId,
zipped_custom_code: zippedCustomCode,
uid: `web_${Date.now()}`,
branch_name: apiClient.branchName,
serialized_yaml: finish.serializedYaml,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Delayed finish overwrites newer project dependencies

If dependencies change after reconciliation, finishProvisionedDeploy pushes its earlier complete YAML snapshot. Newer project dependencies disappear from FlutterFlow.

Learn more

The normal deploy merges generated dependencies with the current project manifest in resolveProjectPubspec. The finish action instead retains that earlier merged manifest and sends it as the complete serialized_yaml. The user can leave the terminal open while another deploy or project editor adds packages, and the finish push replaces that newer manifest. Its bundle file map can also contain non-class artifacts created before the delay.

Example: Deploy A prepares a manifest containing intl, then provisioning times out and the Finish button appears. Deploy B adds http; clicking Finish for A uploads A's older manifest and removes http.

Recommended fix: Before constructing the finish request, fetch the current project manifest and merge only the interrupted deploy's required dependencies into it. Also reconcile remaining bundle artifacts against the fresh project export before writing them.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

file_map: syncMetadata.fileMapContents,
functions_map: syncMetadata.functionsMapContents,
};

invalidateProjectSourceCache(apiClient);
commitProgress.set("push");
const result = await pushCodeWithUiTimeout(apiClient, pushRequest);

if (result.success) {
renderCommitTerminal({
...finish.result,
success: true,
warnings: result.errorMap ? Array.from(result.errorMap.entries()) : [],
});
return;
}
const error = new Error(
result.errorMessage || getFlutterFlowErrorMessage(result.responseCode),
);
error.remoteRefusal = true;
throw error;
} catch (error) {
renderCommitTerminal({
...finish.result,
success: false,
error: error.message,
unconfirmed: error instanceof UnconfirmedDeployError,
remoteRefusal: error.remoteRefusal === true,
});
Comment on lines +7967 to +7974

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Failed finish obscures landed classes

A rejected finish push displays a total failure, although reconciliation already found the classes in FlutterFlow. The terminal tells users nothing was applied.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
}

/**
* Closes the shared terminal modal (partial / unconfirmed outcomes).
* @param {Event} [event] - Optional click event
Expand Down Expand Up @@ -8229,6 +8389,7 @@ window.openCommitConfirmModal = openCommitConfirmModal;
window.closeCommitConfirmModal = closeCommitConfirmModal;
window.closeCommitSuccessModal = closeCommitSuccessModal
window.closeCommitTerminalModal = closeCommitTerminalModal
window.finishProvisionedDeploy = finishProvisionedDeploy
window.showCommitPartialModal = showCommitPartialModal
window.showCommitUnconfirmedModal = showCommitUnconfirmedModal
window.showCommitSuccessModal = showCommitSuccessModal
Expand Down
37 changes: 37 additions & 0 deletions src/flutterFlowCodeFileProvisioning.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,40 @@ export function excludeProvisionedCodeFiles(fileMap, entries) {
),
);
}

/**
* Whether every class a provisioning request attempted is now present in the
* project's fresh export. When true the classes have landed and only step 3 —
* the pubspec push — remains, so the interrupted deploy is finishable without
* re-running the slower class write.
* @param {{landed: Array, notLanded: Array}} partition - Outcome of
* `partitionProvisionedCodeFiles`
* @returns {boolean}
*/
export function isDeployFinishable(partition) {
return partition.notLanded.length === 0 && partition.landed.length > 0;
}

/**
* Builds the file map for a "finish deploy" — the step-3 push that completes
* a deploy whose classes have already landed but whose provisioning response
* was lost. The landed classes are excluded: they are already in the project,
* so re-writing them would needlessly run the class/dependency handling they
* just went through. The merged pubspec is added as the remaining entry. This
* mirrors what the normal step-3 request already does, since it also excludes
* classes the runner wrote (see `provisionMissingCodeFiles`).
* @param {Map<string, Object>} fileMap - Entries a provisioning request was writing
* @param {Array<{path: string}>} landedEntries - Entries confirmed present in the project
* @param {string} serializedYaml - Merged pubspec.yaml to push
* @returns {Map<string, Object>} The finish-push file map
*/
export function buildFinishPushFileMap(fileMap, landedEntries, serializedYaml) {
const remaining = excludeProvisionedCodeFiles(fileMap, landedEntries);
const out = new Map(remaining);
out.set("pubspec.yaml", {
content: serializedYaml,
type: "D",
path: "pubspec.yaml",
});
return out;
}
Loading
Loading