feat(lanes): add POST /:id/sync-base route (E2)

This commit is contained in:
2026-08-05 10:47:02 +07:00
parent d12e9410e3
commit 40b1d0e0fb
+53 -1
View File
@@ -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