From 40b1d0e0fbe65e1fe0c00ed236a94c7c515be9e2 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 5 Aug 2026 10:47:02 +0700 Subject: [PATCH] feat(lanes): add POST /:id/sync-base route (E2) --- server/routes/lanes.js | 54 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/server/routes/lanes.js b/server/routes/lanes.js index ea265d3..75fe584 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -32,6 +32,7 @@ const { slugify, } = require("../lib/worktree"); const { withLaneLock } = require("../lib/lane-lock"); +const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync"); const { HOOKS, runHook, resolveProfile } = require("../lib/lane-profile"); const { slotDirs } = require("../lib/lane-slots"); const { @@ -563,7 +564,15 @@ function sendLifecycleError(res, err) { /** Map a runtime error onto its status code. */ function sendRuntimeError(res, err) { - const badRequest = ["ENOPROFILE", "ENOHOOK", "EBADLANEDIR", "EBADSVC"]; + const badRequest = [ + "ENOPROFILE", + "ENOHOOK", + "EBADLANEDIR", + "EBADSVC", + "EBADBRANCH", + "EUNRESOLVED", + "EMERGEUNCOMMITTED", + ]; if (badRequest.includes(err.code)) { return res.status(400).json({ error: { @@ -759,6 +768,49 @@ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => { }); }); +/** + * The ONE sanctioned merge in the ship-feature-lane pipeline: origin/development + * INTO a feature branch, gated by a migration-number collision preflight. + * Synchronous — a fetch + collision-check + merge is seconds of git work, not + * the minutes a build/test hook can take, so this follows GET /:id/git's + * pattern rather than the hook route's 202-and-broadcast. + * + * Returns 200 with {code: 0|4|5, ...} for every DOCUMENTED outcome — a + * migration collision or a left-in-place conflict is an expected result, not + * an HTTP error. A malformed request, a missing profile, or an out-of-order + * --continue is the only case that answers with an `error` body. + * + * Never writes stage/status/notes — same boundary the hook and runtime + * routes already keep; the caller decides what a collision or conflict means + * for the lane's declared stage. + */ +router.post("/:id/sync-base", sameOriginGuard, async (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + let profile; + try { + profile = requireProfile(lane); + } catch (err) { + return sendRuntimeError(res, err); + } + + const mode = ["check", "merge", "continue"].includes(req.body?.mode) ? req.body.mode : "merge"; + const branch = + typeof req.body?.branch === "string" && req.body.branch ? req.body.branch : undefined; + + try { + const result = await withLaneLock(lane.id, () => { + const current = lanesLib.getLane(lane.id); + if (mode === "check") return checkSync(current, profile, branch); + if (mode === "continue") return continueSync(current, profile, branch); + return mergeSync(current, profile, branch); + }); + res.json(result); + } catch (err) { + sendRuntimeError(res, err); + } +}); + /** * Lane control. Deliberately thin: every action maps onto one existing * run-spawner call. There is no queue, no chaining, no gate evaluation — the