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", + ]); +}); +