diff --git a/app.js b/app.js
index d7a85dd..be2abeb 100644
--- a/app.js
+++ b/app.js
@@ -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";
@@ -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)}). ` +
@@ -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(
@@ -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)) {
+ 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;
}
@@ -4542,6 +4566,9 @@ async function executeCommit(code, options = {}) {
targetIdentity,
state: commitState.currentState,
elapsedTime: commitState.getElapsedTime(),
+ ...(error.finishDeploy
+ ? { finishDeploy: mergeFinishDeployContext(error.finishDeploy, targetIdentity, commitState) }
+ : {}),
};
}
@@ -4749,6 +4776,9 @@ async function executeBundleCommit(bundlePlan, options = {}) {
targetIdentity,
state: commitState.currentState,
elapsedTime: commitState.getElapsedTime(),
+ ...(error.finishDeploy
+ ? { finishDeploy: mergeFinishDeployContext(error.finishDeploy, targetIdentity, commitState) }
+ : {}),
};
}
@@ -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 not cancelled. " +
+ "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. " +
+ "";
+ }
+
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 not cancelled. " +
- "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());
+ 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,
+ 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,
+ });
+ }
+}
+
/**
* Closes the shared terminal modal (partial / unconfirmed outcomes).
* @param {Event} [event] - Optional click event
@@ -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
diff --git a/src/flutterFlowCodeFileProvisioning.js b/src/flutterFlowCodeFileProvisioning.js
index 49f40e5..99a93b4 100644
--- a/src/flutterFlowCodeFileProvisioning.js
+++ b/src/flutterFlowCodeFileProvisioning.js
@@ -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} 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} 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;
+}
diff --git a/src/flutterFlowCodeFileProvisioning.test.js b/src/flutterFlowCodeFileProvisioning.test.js
index d6849b6..29fe809 100644
--- a/src/flutterFlowCodeFileProvisioning.test.js
+++ b/src/flutterFlowCodeFileProvisioning.test.js
@@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
+ buildFinishPushFileMap,
excludeProvisionedCodeFiles,
findMissingCodeFiles,
+ isDeployFinishable,
partitionProvisionedCodeFiles,
} from "./flutterFlowCodeFileProvisioning.js";
@@ -131,3 +133,114 @@ test("partitionProvisionedCodeFiles splits attempted entries by fresh export", (
2,
);
});
+
+test("isDeployFinishable is true only when every attempted class landed", () => {
+ const allLanded = partitionProvisionedCodeFiles(
+ [
+ { className: "Alpha", path: "lib/custom_code/alpha.dart" },
+ { className: "Beta", path: "lib/custom_code/beta.dart" },
+ ],
+ new Map([
+ ["lib/custom_code/alpha.dart", "class Alpha {}"],
+ ["lib/custom_code/beta.dart", "class Beta {}"],
+ ]),
+ );
+ assert.equal(isDeployFinishable(allLanded), true);
+
+ const partlyLanded = partitionProvisionedCodeFiles(
+ [
+ { className: "Alpha", path: "lib/custom_code/alpha.dart" },
+ { className: "Beta", path: "lib/custom_code/beta.dart" },
+ ],
+ new Map([["lib/custom_code/alpha.dart", "class Alpha {}"]]),
+ );
+ assert.equal(isDeployFinishable(partlyLanded), false);
+
+ const nothingLanded = partitionProvisionedCodeFiles(
+ [{ className: "Alpha", path: "lib/custom_code/alpha.dart" }],
+ new Map(),
+ );
+ assert.equal(isDeployFinishable(nothingLanded), false);
+});
+
+// DONE CRITERION (client): a deploy whose step-2 response is lost must still
+// be finishable — the classes are present and the finish action pushes the
+// pubspec without re-provisioning them. These tests drive the exact pure
+// decision path app.js uses after a timed-out provision: the re-read splits
+// the attempted entries, finishability comes from all of them landing, and
+// the finish file map carries only the merged pubspec.
+test("finish action after an all-landed reconcile pushes the pubspec only", () => {
+ const alphaPath = "lib/custom_code/alpha.dart";
+ const fileMap = new Map([
+ [
+ "alpha.dart",
+ {
+ artifactName: "AlphaGen",
+ content: "class AlphaGen {}",
+ type: "C",
+ path: alphaPath,
+ },
+ ],
+ ]);
+ const mergedYaml = "name: app\ndependencies:\n intl: ^0.19.0\n";
+
+ // Step 2's response was lost; a fresh project export shows the class landed.
+ const partition = partitionProvisionedCodeFiles(
+ [
+ { className: "AlphaGen", path: alphaPath },
+ ],
+ new Map([[alphaPath, "class AlphaGen {}"]]),
+ );
+
+ assert.equal(isDeployFinishable(partition), true);
+
+ const finishMap = buildFinishPushFileMap(
+ fileMap,
+ partition.landed,
+ mergedYaml,
+ );
+
+ // The pubspec is present and is the merged yaml (the pubspec push happens)...
+ assert.deepEqual(Array.from(finishMap.keys()), ["pubspec.yaml"]);
+ assert.deepEqual(finishMap.get("pubspec.yaml"), {
+ content: mergedYaml,
+ type: "D",
+ path: "pubspec.yaml",
+ });
+ // ...and the landed class is not re-provisioned (absent from the sync map).
+ assert.equal(finishMap.has("alpha.dart"), false);
+});
+
+test("finish file map excludes every landed class but keeps not-yet-landed ones", () => {
+ const alphaPath = "lib/custom_code/alpha.dart";
+ const betaPath = "lib/custom_code/beta.dart";
+ const fileMap = new Map([
+ [
+ "alpha.dart",
+ { type: "C", path: alphaPath },
+ ],
+ [
+ "beta.dart",
+ { type: "C", path: betaPath },
+ ],
+ ]);
+
+ const partition = partitionProvisionedCodeFiles(
+ [{ path: alphaPath }, { path: betaPath }],
+ new Map([[alphaPath, "class Alpha {}"]]),
+ );
+
+ const finishMap = buildFinishPushFileMap(
+ fileMap,
+ partition.landed,
+ "name: app\n",
+ );
+
+ // The landed class is excluded; the not-yet-landed one stays so a retry can
+ // still finish it, and the pubspec is added.
+ assert.deepEqual(Array.from(finishMap.keys()).sort(), [
+ "beta.dart",
+ "pubspec.yaml",
+ ]);
+});
+