specrails-desktop 2.29.2 → 2.29.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CDMzpGXd.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-BVTacapZ.js} +1 -1
- package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-D9bhFyuy.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-DAjN8Au5.js} +1 -1
- package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-CebykajE.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BiPrVvgv.js} +1 -1
- package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Dw9eQFRS.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-cCG_JAEi.js} +1 -1
- package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-C1kZ_mRP.js} +1 -1
- package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-3GivxWJm.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-KuZXGdr8.js} +1 -1
- package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DWpRALkB.js} +1 -1
- package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-mrXDKafj.js} +1 -1
- package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-CUYXjcoc.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-CwjBD5Gm.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-CO9ton0-.js} +1 -1
- package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-CLL5UuvN.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext-DJAkRYps.js} +1 -1
- package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-DZvCC5Ri.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-C7Hesadg.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-DczCzUzP.js} +1 -1
- package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-CFh7GOv5.js} +4 -4
- package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-Cm9rhdnG.js} +1 -1
- package/client/dist/assets/agent-BXc59ARK2.js +1 -0
- package/client/dist/assets/agent-BbIFXdDq.js +1 -0
- package/client/dist/assets/agent-BqEfA5u5.js +1 -0
- package/client/dist/assets/agent-BtGzgTC4.js +1 -0
- package/client/dist/assets/agent-CIhys_5d.js +1 -0
- package/client/dist/assets/agent-CJPaqsVR.js +1 -0
- package/client/dist/assets/agent-n-hY4GuL.js +1 -0
- package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
- package/client/dist/assets/common-6MLbD-jM.js +1 -0
- package/client/dist/assets/common-BKGEu4rm.js +1 -0
- package/client/dist/assets/common-BNbnv8cE2.js +1 -0
- package/client/dist/assets/common-BqGWtDbt.js +1 -0
- package/client/dist/assets/common-DBtTzeYm2.js +1 -0
- package/client/dist/assets/common-DEW3Nr8n.js +1 -0
- package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
- package/client/dist/assets/common-mkrEcW-Y.js +1 -0
- package/client/dist/assets/dashboard-CHCEkds4.js +1 -0
- package/client/dist/assets/dashboard-Cqmmbh3l2.js +1 -0
- package/client/dist/assets/dashboard-CrlTV1ms.js +1 -0
- package/client/dist/assets/dashboard-Cslu_GCH.js +1 -0
- package/client/dist/assets/dashboard-DUU8-_Rg.js +1 -0
- package/client/dist/assets/dashboard-DweT3Y4R.js +1 -0
- package/client/dist/assets/dashboard-Dy2Um-US2.js +1 -0
- package/client/dist/assets/dashboard-WhzOQw1S.js +1 -0
- package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-BERxOT0X.js} +1 -1
- package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-Di8lflPo.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-BAXZRbEO.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DwzZXHtg.js} +1 -1
- package/client/dist/assets/{i18n-CgaRxpII.js → i18n-cK5pPbj8.js} +2 -2
- package/client/dist/assets/index-BT6xDQjB.css +2 -0
- package/client/dist/assets/index-CCq_kIrF.js +76 -0
- package/client/dist/assets/{spending-C4p06nen.js → spending-BLb5pj6G.js} +1 -1
- package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-DR1_jL56.js} +1 -1
- package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-D4DxBGPU.js} +1 -1
- package/client/dist/index.html +14 -14
- package/docs/internals/safe-pr-review-flow.md +250 -38
- package/package.json +1 -1
- package/server/dist/active-pr-continuation.js +352 -53
- package/server/dist/agent-chat-manager.js +29 -12
- package/server/dist/agent-operator-prompt.js +9 -0
- package/server/dist/agent-store.js +19 -4
- package/server/dist/db.js +21 -0
- package/server/dist/mcp/tools/rails.js +8 -0
- package/server/dist/pr-lifecycle.js +100 -8
- package/server/dist/project-git.js +68 -3
- package/server/dist/rail-isolated-launch.js +719 -124
- package/server/dist/rail-pr-decision.js +1073 -181
- package/server/dist/rail-pr-recovery-git.js +235 -0
- package/server/dist/rail-pr-store.js +120 -9
- package/server/dist/rail-worktree-release.js +376 -4
- package/server/dist/rails-router.js +196 -10
- package/server/dist/worktree-manager.js +149 -18
- package/server/dist/worktree-overlay.js +166 -4
- package/client/dist/assets/agent-4rmXoo_p.js +0 -1
- package/client/dist/assets/agent-BxZuNGih.js +0 -1
- package/client/dist/assets/agent-C3uXOYv_.js +0 -1
- package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
- package/client/dist/assets/agent-CASKrhG12.js +0 -1
- package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
- package/client/dist/assets/agent-DitUxYh2.js +0 -1
- package/client/dist/assets/agent-Doow4X7o.js +0 -1
- package/client/dist/assets/common-CD1QHriA2.js +0 -1
- package/client/dist/assets/common-CwwPaRxx.js +0 -1
- package/client/dist/assets/common-Dg2jaX7d.js +0 -1
- package/client/dist/assets/common-DuBwj6tK.js +0 -1
- package/client/dist/assets/common-MRj9MHcc.js +0 -1
- package/client/dist/assets/common-O4ZpnMLp.js +0 -1
- package/client/dist/assets/common-fyeF0v7C2.js +0 -1
- package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
- package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
- package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
- package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
- package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
- package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
- package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
- package/client/dist/assets/dashboard-owcDucDi.js +0 -1
- package/client/dist/assets/index-BflYQpWm.css +0 -2
- package/client/dist/assets/index-C1_-BTXy.js +0 -72
|
@@ -21,6 +21,7 @@ const rail_isolation_1 = require("./rail-isolation");
|
|
|
21
21
|
const rail_pr_store_1 = require("./rail-pr-store");
|
|
22
22
|
const loop_effect_1 = require("./loop-effect");
|
|
23
23
|
const rail_pr_decision_1 = require("./rail-pr-decision");
|
|
24
|
+
const active_pr_continuation_1 = require("./active-pr-continuation");
|
|
24
25
|
const rail_isolated_launch_1 = require("./rail-isolated-launch");
|
|
25
26
|
const worktree_manager_1 = require("./worktree-manager");
|
|
26
27
|
const rail_worktree_release_1 = require("./rail-worktree-release");
|
|
@@ -40,12 +41,21 @@ const VALID_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
|
|
|
40
41
|
function prDeliveryContinuesTickets(delivery, ticketIds) {
|
|
41
42
|
if (delivery.decision !== 'pr_draft' && delivery.decision !== 'pr_ready')
|
|
42
43
|
return false;
|
|
43
|
-
if (!delivery.prUrl || !delivery.branch || delivery.prState !== 'pr-created')
|
|
44
|
+
if (!delivery.prUrl || !delivery.branch || !delivery.deliverySha || delivery.prState !== 'pr-created')
|
|
44
45
|
return false;
|
|
45
46
|
const covered = new Set(delivery.ticketIds);
|
|
46
47
|
const requested = new Set(ticketIds);
|
|
47
48
|
return requested.size > 0 && requested.size === covered.size && [...requested].every((id) => covered.has(id));
|
|
48
49
|
}
|
|
50
|
+
function prDeliveryCheckoutBlock(delivery) {
|
|
51
|
+
if (delivery.deliveryOutcome === 'blocked') {
|
|
52
|
+
return 'delivery is blocked and has no safely checkoutable result';
|
|
53
|
+
}
|
|
54
|
+
if (!delivery.deliverySha) {
|
|
55
|
+
return 'delivery has no verified commit available for checkout';
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
49
59
|
function emitPrDeliveryUpdate(c, prDeliveryId) {
|
|
50
60
|
const row = (0, rail_pr_store_1.getPrDelivery)(c.db, prDeliveryId);
|
|
51
61
|
if (!row)
|
|
@@ -268,6 +278,40 @@ function createRailsRouter() {
|
|
|
268
278
|
res.status(500).json({ error: 'Failed to update rail profile' });
|
|
269
279
|
}
|
|
270
280
|
});
|
|
281
|
+
// GET /rails/:railIndex/pr-candidates — display-only open-PR suggestions for
|
|
282
|
+
// the "deliver into existing PR" launch affordance. Reuses the automatic-
|
|
283
|
+
// continuation matchers WITHOUT the ticket-status gate: suggestions are
|
|
284
|
+
// informational, selection is always an explicit user action. No side effects.
|
|
285
|
+
router.get('/:railIndex/pr-candidates', async (req, res) => {
|
|
286
|
+
const c = ctx(req);
|
|
287
|
+
const railIndex = parseInt(req.params.railIndex, 10);
|
|
288
|
+
if (isNaN(railIndex) || railIndex < 0 || railIndex >= rails_store_1.MAX_RAILS) {
|
|
289
|
+
res.status(400).json({ error: 'Invalid rail index' });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const rail = (0, rails_store_1.getRail)(c.db, railIndex);
|
|
294
|
+
const candidates = await (0, active_pr_continuation_1.listPrCandidatesForTickets)({
|
|
295
|
+
db: c.db,
|
|
296
|
+
exec: pr_publisher_1.defaultExec,
|
|
297
|
+
repoDir: c.project.path,
|
|
298
|
+
ticketIds: [...(rail?.ticketIds ?? [])],
|
|
299
|
+
getTicketSpec: (ticketId) => {
|
|
300
|
+
try {
|
|
301
|
+
return c.getTicketSpec(ticketId) ?? undefined;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
res.json({ candidates });
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
console.error('[rails-router] pr-candidates error:', err);
|
|
312
|
+
res.status(500).json({ error: 'Failed to list PR candidates' });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
271
315
|
// PUT /rails/:railIndex/engine — set the AI engine override for a rail
|
|
272
316
|
// Body: { aiEngine: string | null } (null = use the project's primary provider)
|
|
273
317
|
router.put('/:railIndex/engine', (req, res) => {
|
|
@@ -343,7 +387,16 @@ function createRailsRouter() {
|
|
|
343
387
|
return;
|
|
344
388
|
}
|
|
345
389
|
let { mode = 'implement' } = req.body ?? {};
|
|
346
|
-
const { profileName, aiEngine, model, loopId: rawLoopId, reasoning_effort, originConversationId, originSurface } = req.body ?? {};
|
|
390
|
+
const { profileName, aiEngine, model, loopId: rawLoopId, reasoning_effort, originConversationId, originSurface, targetPrNumber } = req.body ?? {};
|
|
391
|
+
// Explicit target PR (deliver-rail-into-existing-pr): the user names an
|
|
392
|
+
// existing open PR as the delivery destination. Shape-validated here;
|
|
393
|
+
// resolved and verified inside the isolated launch, fail-closed.
|
|
394
|
+
if (targetPrNumber !== undefined && targetPrNumber !== null) {
|
|
395
|
+
if (typeof targetPrNumber !== 'number' || !Number.isSafeInteger(targetPrNumber) || targetPrNumber <= 0 || targetPrNumber > 1_000_000_000) {
|
|
396
|
+
res.status(400).json({ error: 'invalid_target_pr', detail: 'targetPrNumber must be a positive integer' });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
347
400
|
let loopId = rawLoopId;
|
|
348
401
|
// Origin link (safe-pr-review-flow): an agent-chat/MCP launch tags itself so
|
|
349
402
|
// the PR decision can later be posted back into the launching conversation.
|
|
@@ -386,6 +439,13 @@ function createRailsRouter() {
|
|
|
386
439
|
res.status(403).json({ error: 'Loops are disabled on this server' });
|
|
387
440
|
return;
|
|
388
441
|
}
|
|
442
|
+
// An explicit target PR is only meaningful on the isolated PR-delivery
|
|
443
|
+
// path. Reject early when that path is structurally unavailable rather
|
|
444
|
+
// than silently launching fresh work next to the designated PR.
|
|
445
|
+
if (typeof targetPrNumber === 'number' && (!(0, feature_flags_1.isLoopsEnabled)() || !(0, rail_isolation_1.isRailPrDeliveryEnabled)())) {
|
|
446
|
+
res.status(400).json({ error: 'target_pr_requires_pr_mode', detail: 'targetPrNumber requires loops and PR delivery to be enabled' });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
389
449
|
// Freestyle model picker: optional, validated against the allow-list.
|
|
390
450
|
// Ignored for non-freestyle modes (they use the orchestrator model).
|
|
391
451
|
if (mode === 'freestyle' && model !== undefined && model !== null) {
|
|
@@ -562,11 +622,23 @@ function createRailsRouter() {
|
|
|
562
622
|
res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
|
|
563
623
|
return;
|
|
564
624
|
}
|
|
625
|
+
// Explicit target vs an undecided continuable delivery: the slot's
|
|
626
|
+
// active generation owns its PR. A DIFFERENT explicit target would
|
|
627
|
+
// append to the undecided branches → 409; the SAME PR is redundant
|
|
628
|
+
// (the continuation contract below already drives it) and is dropped.
|
|
629
|
+
if (pendingSnapshot && typeof targetPrNumber === 'number' && pendingSnapshot.prNumber !== targetPrNumber) {
|
|
630
|
+
res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
565
633
|
continuablePrDelivery = pendingSnapshot;
|
|
566
634
|
}
|
|
567
635
|
const useIsolation = (0, rail_isolation_1.isolationApplies)({
|
|
568
636
|
loopsEnabled: (0, feature_flags_1.isLoopsEnabled)(), scope, ticketCount: rail.ticketIds.length, readOnly: loopReadOnly,
|
|
569
637
|
});
|
|
638
|
+
if (typeof targetPrNumber === 'number' && !useIsolation) {
|
|
639
|
+
res.status(400).json({ error: 'target_pr_requires_pr_mode', detail: 'targetPrNumber requires an isolated (worktree) launch; this launch would run in the shared checkout' });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
570
642
|
if (continuablePrDelivery && !loopReadOnly && !useIsolation) {
|
|
571
643
|
res.status(409).json({
|
|
572
644
|
error: 'pr_continuation_isolation_required',
|
|
@@ -580,6 +652,13 @@ function createRailsRouter() {
|
|
|
580
652
|
// unborn HEAD can't be branched). Fall back (with a message) otherwise.
|
|
581
653
|
const status = await (0, worktree_manager_1.repoIsolationStatus)(worktree_manager_1.defaultGitRunner, c.project.path);
|
|
582
654
|
if (status !== 'ok') {
|
|
655
|
+
if (typeof targetPrNumber === 'number') {
|
|
656
|
+
res.status(400).json({
|
|
657
|
+
error: 'target_pr_requires_pr_mode',
|
|
658
|
+
detail: `targetPrNumber requires worktree isolation, which is unavailable (${status})`,
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
583
662
|
if (continuablePrDelivery) {
|
|
584
663
|
res.status(409).json({
|
|
585
664
|
error: 'pr_continuation_isolation_required',
|
|
@@ -603,10 +682,18 @@ function createRailsRouter() {
|
|
|
603
682
|
deliveryId: continuablePrDelivery.id,
|
|
604
683
|
decision: continuablePrDelivery.decision,
|
|
605
684
|
branch: continuablePrDelivery.branch,
|
|
685
|
+
baseBranch: continuablePrDelivery.baseBranch,
|
|
606
686
|
prUrl: continuablePrDelivery.prUrl,
|
|
607
687
|
prNumber: continuablePrDelivery.prNumber,
|
|
688
|
+
deliverySha: continuablePrDelivery.deliverySha,
|
|
608
689
|
},
|
|
609
690
|
} : {}),
|
|
691
|
+
// Explicit target only drives fresh launches; when the slot has
|
|
692
|
+
// a continuable delivery for the same PR, the continuation
|
|
693
|
+
// contract above is the (stricter) authority.
|
|
694
|
+
...(typeof targetPrNumber === 'number' && !continuablePrDelivery
|
|
695
|
+
? { explicitPrTarget: { prNumber: targetPrNumber } }
|
|
696
|
+
: {}),
|
|
610
697
|
});
|
|
611
698
|
res.status(202).json({ loopRunIds: ids, railIndex, mode, isolated: true });
|
|
612
699
|
return;
|
|
@@ -619,6 +706,16 @@ function createRailsRouter() {
|
|
|
619
706
|
});
|
|
620
707
|
return;
|
|
621
708
|
}
|
|
709
|
+
// Explicit-target validation failures are the USER's answer being
|
|
710
|
+
// unusable — fail closed with the machine-readable code; never
|
|
711
|
+
// fall back to a fresh shared-cwd run next to the designated PR.
|
|
712
|
+
if (err instanceof active_pr_continuation_1.ExplicitPrTargetError) {
|
|
713
|
+
res.status(err.code === 'target_pr_not_found' ? 404 : 409).json({
|
|
714
|
+
error: err.code,
|
|
715
|
+
detail: err.message,
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
622
719
|
if (continuablePrDelivery || err instanceof rail_isolated_launch_1.PrContinuationIsolationError) {
|
|
623
720
|
const detail = err instanceof Error ? err.message : String(err);
|
|
624
721
|
res.status(409).json({
|
|
@@ -881,7 +978,7 @@ function createRailsRouter() {
|
|
|
881
978
|
// POST /rails/pr-decision — the ONE decision action (safe-pr-review-flow) both
|
|
882
979
|
// surfaces (dashboard rail row + agent-chat card) call on a rail_pr_deliveries
|
|
883
980
|
// row: create-pr | publish | discard/dismiss | poll/reopen | merge-local |
|
|
884
|
-
// acknowledge-no-changes, compare-and-set-guarded by
|
|
981
|
+
// acknowledge-no-changes | recover-and-retry, compare-and-set-guarded by
|
|
885
982
|
// expectedDecision (a raced concurrent answer loses with 409 stale_decision).
|
|
886
983
|
// Replaces the stateless v1 POST /rails/pr-review passthrough. The route only
|
|
887
984
|
// validates and delegates — the action logic lives in rail-pr-decision.ts.
|
|
@@ -894,7 +991,7 @@ function createRailsRouter() {
|
|
|
894
991
|
return;
|
|
895
992
|
}
|
|
896
993
|
if (!(0, rail_pr_decision_1.isPrDecisionAction)(action)) {
|
|
897
|
-
res.status(400).json({ error:
|
|
994
|
+
res.status(400).json({ error: `action must be one of: ${rail_pr_decision_1.PR_DECISION_ACTIONS.join(', ')}` });
|
|
898
995
|
return;
|
|
899
996
|
}
|
|
900
997
|
if (typeof expectedDecision !== 'string' || !expectedDecision) {
|
|
@@ -968,7 +1065,21 @@ function createRailsRouter() {
|
|
|
968
1065
|
res.status(404).json({ error: 'Unknown prDeliveryId' });
|
|
969
1066
|
return;
|
|
970
1067
|
}
|
|
1068
|
+
const active = (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, row.rail_index);
|
|
1069
|
+
if (!active || active.id !== row.id) {
|
|
1070
|
+
res.status(409).json({
|
|
1071
|
+
error: 'stale_decision',
|
|
1072
|
+
current: active?.decision ?? null,
|
|
1073
|
+
currentPrDeliveryId: active?.id ?? null,
|
|
1074
|
+
});
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
971
1077
|
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
1078
|
+
const checkoutBlock = prDeliveryCheckoutBlock(snap);
|
|
1079
|
+
if (checkoutBlock) {
|
|
1080
|
+
res.status(409).json({ error: 'checkout_not_deliverable', detail: checkoutBlock });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
972
1083
|
if (!snap.branch || !snap.prUrl) {
|
|
973
1084
|
res.status(409).json({ error: 'checkout_unavailable', detail: 'delivery has no PR branch' });
|
|
974
1085
|
return;
|
|
@@ -976,20 +1087,95 @@ function createRailsRouter() {
|
|
|
976
1087
|
try {
|
|
977
1088
|
const outcome = await (0, repo_lock_1.withRepoLock)(c.project.path, async () => {
|
|
978
1089
|
admission.assertCurrent();
|
|
1090
|
+
const current = (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, row.rail_index);
|
|
1091
|
+
if (!current || current.id !== row.id) {
|
|
1092
|
+
return {
|
|
1093
|
+
ok: false,
|
|
1094
|
+
status: 409,
|
|
1095
|
+
error: 'stale_decision',
|
|
1096
|
+
detail: 'The delivery generation changed before checkout.',
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
const currentSnap = (0, rail_pr_store_1.toPrDeliverySnapshot)(current);
|
|
1100
|
+
const currentCheckoutBlock = prDeliveryCheckoutBlock(currentSnap);
|
|
1101
|
+
if (currentCheckoutBlock) {
|
|
1102
|
+
return {
|
|
1103
|
+
ok: false,
|
|
1104
|
+
status: 409,
|
|
1105
|
+
error: 'checkout_not_deliverable',
|
|
1106
|
+
detail: currentCheckoutBlock,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
if (!currentSnap.branch || !currentSnap.prUrl) {
|
|
1110
|
+
return {
|
|
1111
|
+
ok: false,
|
|
1112
|
+
status: 409,
|
|
1113
|
+
error: 'checkout_not_deliverable',
|
|
1114
|
+
detail: 'The current delivery no longer has an attached PR branch.',
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const cleanliness = await (0, project_git_1.inspectProjectCheckoutCleanliness)(c.project.path);
|
|
1118
|
+
if (!cleanliness.ok) {
|
|
1119
|
+
return {
|
|
1120
|
+
ok: false,
|
|
1121
|
+
status: 409,
|
|
1122
|
+
error: 'checkout_safety_unknown',
|
|
1123
|
+
detail: cleanliness.detail,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
if (!cleanliness.clean) {
|
|
1127
|
+
return { ok: false, status: 409, error: 'checkout_dirty', detail: 'Working tree has uncommitted changes. Commit or stash them before checkout.' };
|
|
1128
|
+
}
|
|
979
1129
|
const info = await (0, project_git_1.getProjectGitInfo)(c.project.path);
|
|
980
1130
|
if (!info.git) {
|
|
981
1131
|
return { ok: false, status: 409, error: 'checkout_unavailable', detail: 'project is not a git repository' };
|
|
982
1132
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
1133
|
+
let archived = false;
|
|
1134
|
+
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
987
1135
|
db: c.db,
|
|
988
1136
|
git: worktree_manager_1.defaultGitRunner,
|
|
989
1137
|
repoDir: c.project.path,
|
|
990
|
-
worktreeIds:
|
|
1138
|
+
worktreeIds: currentSnap.worktreeIds,
|
|
1139
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(currentSnap.branches),
|
|
1140
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(currentSnap.branches),
|
|
1141
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(currentSnap.branches),
|
|
1142
|
+
onSafetyArchive: (archive) => {
|
|
1143
|
+
archived = true;
|
|
1144
|
+
if (!(0, rail_pr_store_1.appendPrDeliverySafetyArchive)(c.db, row.id, archive)) {
|
|
1145
|
+
throw new Error(`delivery ${row.id} disappeared while recording safety archive ${archive}`);
|
|
1146
|
+
}
|
|
1147
|
+
},
|
|
991
1148
|
});
|
|
992
|
-
|
|
1149
|
+
if (archived) {
|
|
1150
|
+
const archivedRow = (0, rail_pr_store_1.getPrDelivery)(c.db, row.id);
|
|
1151
|
+
if (archivedRow) {
|
|
1152
|
+
const archivedSnap = (0, rail_pr_store_1.toPrDeliverySnapshot)(archivedRow);
|
|
1153
|
+
c.broadcast((0, rail_pr_store_1.toRailPrStateMessage)(c.project.id, archivedSnap));
|
|
1154
|
+
if (archivedRow.origin_conversation_id) {
|
|
1155
|
+
(0, agent_chat_registry_1.getAgentChatManager)()?.updatePrDecisionCard(archivedRow.origin_conversation_id, (0, rail_pr_store_1.toPrDecisionCardEnvelope)(c.project.id, archivedSnap));
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
if (cleanupWarnings.length > 0) {
|
|
1160
|
+
return {
|
|
1161
|
+
ok: false,
|
|
1162
|
+
status: 409,
|
|
1163
|
+
error: 'checkout_unavailable',
|
|
1164
|
+
detail: cleanupWarnings[0],
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
// Cleanup just completed with ZERO warnings — clear any stale
|
|
1168
|
+
// cleanup_incomplete evidence persisted by earlier failed release
|
|
1169
|
+
// attempts, so the card stops claiming "Cleanup is incomplete" after
|
|
1170
|
+
// the condition has been resolved. Same-state patch + live broadcast.
|
|
1171
|
+
if (currentSnap.cleanupWarnings.length > 0 || currentSnap.statusCode === 'cleanup_incomplete') {
|
|
1172
|
+
(0, rail_pr_store_1.transitionDecision)(c.db, row.id, currentSnap.decision, currentSnap.decision, {
|
|
1173
|
+
cleanupWarnings: [],
|
|
1174
|
+
...(currentSnap.statusCode === 'cleanup_incomplete' ? { statusCode: null } : {}),
|
|
1175
|
+
});
|
|
1176
|
+
emitPrDeliveryUpdate(c, row.id);
|
|
1177
|
+
}
|
|
1178
|
+
const checkedOut = await (0, project_git_1.checkoutProjectReviewBranch)(c.project.path, currentSnap.branch, currentSnap.deliverySha);
|
|
993
1179
|
if (!checkedOut.ok) {
|
|
994
1180
|
return { ok: false, status: 409, error: 'checkout_failed', detail: checkedOut.error };
|
|
995
1181
|
}
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.PR_NEVER_STAGE_PATHS = exports.defaultGitRunner = void 0;
|
|
36
|
+
exports.PR_NEVER_STAGE_PATHSPEC_ROOTS = exports.PR_NEVER_STAGE_PATHS = exports.defaultGitRunner = void 0;
|
|
37
37
|
exports.isGitRepo = isGitRepo;
|
|
38
38
|
exports.repoIsolationStatus = repoIsolationStatus;
|
|
39
39
|
exports.worktreeBranch = worktreeBranch;
|
|
@@ -63,13 +63,21 @@ const child_process_1 = require("child_process");
|
|
|
63
63
|
const fs = __importStar(require("fs"));
|
|
64
64
|
const path = __importStar(require("path"));
|
|
65
65
|
const win_spawn_1 = require("./util/win-spawn");
|
|
66
|
+
const GIT_RUNNER_TIMEOUT_MS = 120_000;
|
|
66
67
|
exports.defaultGitRunner = {
|
|
67
68
|
run(args, cwd) {
|
|
68
69
|
return new Promise((resolve) => {
|
|
69
70
|
// SystemRoot/ComSpec backfill so worktree + PR-decision git ops don't fail
|
|
70
71
|
// to start under a pkg-stripped Windows sidecar env. No-op on POSIX.
|
|
71
72
|
const env = (0, win_spawn_1.windowsSpawnEnv)();
|
|
72
|
-
(0, child_process_1.execFile)('git', args, {
|
|
73
|
+
(0, child_process_1.execFile)('git', args, {
|
|
74
|
+
cwd,
|
|
75
|
+
env,
|
|
76
|
+
timeout: GIT_RUNNER_TIMEOUT_MS,
|
|
77
|
+
killSignal: 'SIGTERM',
|
|
78
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
}, (err, stdout, stderr) => {
|
|
73
81
|
const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0;
|
|
74
82
|
resolve({ code, stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
|
|
75
83
|
});
|
|
@@ -233,8 +241,80 @@ async function gitRun(git, args, cwd) {
|
|
|
233
241
|
}
|
|
234
242
|
}
|
|
235
243
|
function commitPathspecs(excludePaths) {
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
return [
|
|
245
|
+
'--', '.',
|
|
246
|
+
...exports.PR_NEVER_STAGE_PATHSPEC_ROOTS.map((p) => `:(exclude)${p}`),
|
|
247
|
+
// Overlay names originate in the workspace filesystem. Treat them as
|
|
248
|
+
// literal top-level paths so glob/pathspec metacharacters cannot broaden
|
|
249
|
+
// the never-commit surface.
|
|
250
|
+
...excludePaths.map((p) => `:(top,exclude,literal)${p}`),
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
const INDEX_AUDIT_ARGS = [
|
|
254
|
+
'diff', '--cached', '--name-only', '--no-renames', '-z',
|
|
255
|
+
'--diff-filter=ACDMRTUXB', '--',
|
|
256
|
+
];
|
|
257
|
+
const PR_NEVER_STAGE_ROOTS = [...new Set(exports.PR_NEVER_STAGE_PATHS.map((entry) => entry.endsWith('/**') ? entry.slice(0, -3) : entry))];
|
|
258
|
+
function normalizedGitPath(value) {
|
|
259
|
+
return path.sep === '\\' ? value.replaceAll('\\', '/') : value;
|
|
260
|
+
}
|
|
261
|
+
function isPathAtOrBelow(candidate, root) {
|
|
262
|
+
return candidate === root || candidate.startsWith(`${root}/`);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Collapse a path list to its minimal roots — drop any entry that is at or
|
|
266
|
+
* below another. Overlay-owned entries like `.claude/agent-memory` are
|
|
267
|
+
* SYMLINKS (the overlay links shared memory in). A git pathspec that descends
|
|
268
|
+
* INTO a symlink — `:(exclude).claude/agent-memory/**` or the `/explanations`
|
|
269
|
+
* subpaths — is rejected with `fatal: pathspec ... is beyond a symbolic link`,
|
|
270
|
+
* which aborts the entire `git add`/`git status` and blocks PR delivery. The
|
|
271
|
+
* symlink ROOT alone (`:(exclude).claude/agent-memory`) still excludes all of
|
|
272
|
+
* its contents (dir-level pathspec semantics) without traversing the link, and
|
|
273
|
+
* is equally correct for the copy-fallback case where it is a real directory.
|
|
274
|
+
*/
|
|
275
|
+
function minimalPathRoots(paths) {
|
|
276
|
+
const norm = [...new Set(paths.map((p) => (p.endsWith('/**') ? p.slice(0, -3) : p)))];
|
|
277
|
+
return norm.filter((p) => !norm.some((other) => other !== p && isPathAtOrBelow(p, other)));
|
|
278
|
+
}
|
|
279
|
+
/** Symlink-safe never-stage pathspec roots (see {@link minimalPathRoots}). */
|
|
280
|
+
exports.PR_NEVER_STAGE_PATHSPEC_ROOTS = minimalPathRoots(exports.PR_NEVER_STAGE_PATHS);
|
|
281
|
+
function forbiddenStagedPaths(staged, excludePaths) {
|
|
282
|
+
const overlayRoots = excludePaths.map(normalizedGitPath).filter(Boolean);
|
|
283
|
+
return staged.filter((candidate) => (PR_NEVER_STAGE_ROOTS.some((root) => isPathAtOrBelow(candidate, root))
|
|
284
|
+
|| overlayRoots.some((root) => isPathAtOrBelow(candidate, root))));
|
|
285
|
+
}
|
|
286
|
+
function parseNulTerminatedPaths(result, phase) {
|
|
287
|
+
if (result.code !== 0) {
|
|
288
|
+
return { paths: [], error: `${phase}: ${gitFailure(result, `git diff failed with exit ${result.code}`)}` };
|
|
289
|
+
}
|
|
290
|
+
if (result.stdout === '')
|
|
291
|
+
return { paths: [] };
|
|
292
|
+
if (!result.stdout.endsWith('\0')) {
|
|
293
|
+
return { paths: [], error: `${phase}: git returned a malformed non-NUL-terminated path list` };
|
|
294
|
+
}
|
|
295
|
+
const paths = result.stdout.slice(0, -1).split('\0');
|
|
296
|
+
if (paths.some((entry) => entry.length === 0)) {
|
|
297
|
+
return { paths: [], error: `${phase}: git returned a malformed path list containing an empty entry` };
|
|
298
|
+
}
|
|
299
|
+
return { paths };
|
|
300
|
+
}
|
|
301
|
+
async function auditStagedPaths(git, worktreePath, excludePaths, phase) {
|
|
302
|
+
const result = await gitRun(git, [...INDEX_AUDIT_ARGS], worktreePath);
|
|
303
|
+
const parsed = parseNulTerminatedPaths(result, phase);
|
|
304
|
+
if (parsed.error)
|
|
305
|
+
return { forbidden: [], error: parsed.error };
|
|
306
|
+
return { forbidden: forbiddenStagedPaths(parsed.paths, excludePaths) };
|
|
307
|
+
}
|
|
308
|
+
function forbiddenResetPathspecs(excludePaths) {
|
|
309
|
+
return [
|
|
310
|
+
'--',
|
|
311
|
+
...PR_NEVER_STAGE_ROOTS.map((entry) => `:(top,literal)${entry}`),
|
|
312
|
+
...excludePaths.map(normalizedGitPath).filter(Boolean).map((entry) => `:(top,literal)${entry}`),
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
function describeForbiddenPaths(paths) {
|
|
316
|
+
const shown = paths.slice(0, 5).map((entry) => JSON.stringify(entry)).join(', ');
|
|
317
|
+
return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown;
|
|
238
318
|
}
|
|
239
319
|
/**
|
|
240
320
|
* Commit the worktree's current deliverable changes to its branch and verify
|
|
@@ -244,25 +324,75 @@ function commitPathspecs(excludePaths) {
|
|
|
244
324
|
*/
|
|
245
325
|
async function commitWorktreeAndVerify(git, worktreePath, message, excludePaths = []) {
|
|
246
326
|
const pathspecs = commitPathspecs(excludePaths);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
327
|
+
// The add is deliberately PLAIN — no exclude pathspecs. `git add` exits 1
|
|
328
|
+
// with "The following paths are ignored by one of your .gitignore files"
|
|
329
|
+
// whenever ANY pathspec item (exclude items included) names a git-ignored
|
|
330
|
+
// path, and our own info/exclude block (ensurePrNeverStageExcludes) plus repo
|
|
331
|
+
// .gitignore entries (e.g. `.claude/settings.local.json`) make exactly the
|
|
332
|
+
// excluded paths ignored. Ignored paths can never be staged without `-f`, so
|
|
333
|
+
// the plain add is safe for them; non-ignored overlay files DO get staged
|
|
334
|
+
// here and are unstaged by the audit → reset flow below before the commit,
|
|
335
|
+
// whose `--only` pathspecs (which git commit accepts without the
|
|
336
|
+
// ignored-path check) remain the authoritative exclusion.
|
|
337
|
+
const add = await gitRun(git, ['add', '-A', '--', '.'], worktreePath);
|
|
338
|
+
let indexSafe = false;
|
|
339
|
+
let indexError;
|
|
340
|
+
if (add.code === 0) {
|
|
341
|
+
const initialAudit = await auditStagedPaths(git, worktreePath, excludePaths, 'git index audit failed before commit');
|
|
342
|
+
if (initialAudit.error) {
|
|
343
|
+
indexError = initialAudit.error;
|
|
344
|
+
}
|
|
345
|
+
else if (initialAudit.forbidden.length === 0) {
|
|
346
|
+
indexSafe = true;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
// The plain add routinely stages non-ignored overlay files, and an
|
|
350
|
+
// earlier process may have put private paths in the index. Reset only the
|
|
351
|
+
// prohibited roots back to HEAD: working files remain intact for recovery.
|
|
352
|
+
const reset = await gitRun(git, ['reset', '--quiet', ...forbiddenResetPathspecs(excludePaths)], worktreePath);
|
|
353
|
+
if (reset.code !== 0) {
|
|
354
|
+
indexError = `git could not safely unstage forbidden paths: ${gitFailure(reset, `git reset failed with exit ${reset.code}`)}`;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const finalAudit = await auditStagedPaths(git, worktreePath, excludePaths, 'git index re-audit failed after unstaging forbidden paths');
|
|
358
|
+
if (finalAudit.error) {
|
|
359
|
+
indexError = finalAudit.error;
|
|
360
|
+
}
|
|
361
|
+
else if (finalAudit.forbidden.length > 0) {
|
|
362
|
+
indexError = `forbidden paths remain staged after safe unstage: ${describeForbiddenPaths(finalAudit.forbidden)}`;
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
indexSafe = true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// `--only` makes the allow/exclude pathspecs authoritative at commit time,
|
|
371
|
+
// not merely at the earlier `git add`/audit. This closes the remaining race
|
|
372
|
+
// where another process could stage a private path between audit and commit.
|
|
373
|
+
// Automated delivery also bypasses repository hooks: a pre-commit hook runs
|
|
374
|
+
// after our final audit and could otherwise stage an excluded secret into the
|
|
375
|
+
// commit. User working files and the ordinary index remain preserved.
|
|
376
|
+
const commit = add.code === 0 && indexSafe
|
|
377
|
+
? await gitRun(git, ['commit', '--no-verify', '--only', '-m', message, ...pathspecs], worktreePath)
|
|
378
|
+
: { code: 1, stdout: '', stderr: 'skipped commit because the index was not proven safe' };
|
|
251
379
|
const status = await gitRun(git, ['status', '--porcelain', '--untracked-files=all', ...pathspecs], worktreePath);
|
|
252
380
|
const dirty = status.code === 0
|
|
253
381
|
? status.stdout.split('\n').map((line) => line.trimEnd()).filter(Boolean)
|
|
254
382
|
: [];
|
|
255
383
|
const error = add.code !== 0
|
|
256
384
|
? gitFailure(add, `git add failed with exit ${add.code}`)
|
|
257
|
-
:
|
|
258
|
-
?
|
|
259
|
-
:
|
|
260
|
-
? gitFailure(
|
|
261
|
-
:
|
|
385
|
+
: indexError
|
|
386
|
+
? indexError
|
|
387
|
+
: status.code !== 0
|
|
388
|
+
? gitFailure(status, `git status failed with exit ${status.code}`)
|
|
389
|
+
: commit.code !== 0 && dirty.length > 0
|
|
390
|
+
? gitFailure(commit, `git commit failed with exit ${commit.code}`)
|
|
391
|
+
: undefined;
|
|
262
392
|
return {
|
|
263
393
|
staged: add.code === 0,
|
|
264
394
|
committed: commit.code === 0,
|
|
265
|
-
clean: status.code === 0 && dirty.length === 0,
|
|
395
|
+
clean: indexSafe && status.code === 0 && dirty.length === 0,
|
|
266
396
|
dirty,
|
|
267
397
|
...(error ? { error } : {}),
|
|
268
398
|
};
|
|
@@ -276,11 +406,12 @@ async function commitWorktreeAndVerify(git, worktreePath, message, excludePaths
|
|
|
276
406
|
async function commitWorktree(git, worktreePath, message, excludePaths = []) {
|
|
277
407
|
await commitWorktreeAndVerify(git, worktreePath, message, excludePaths);
|
|
278
408
|
}
|
|
279
|
-
/** Remove a worktree
|
|
280
|
-
*
|
|
281
|
-
*
|
|
409
|
+
/** Remove a worktree and optionally delete its branch. Explicit cleanup keeps
|
|
410
|
+
* the historical force default; automatic cleanup opts out. Removal failures
|
|
411
|
+
* throw so callers do not mark the ledger released while it is still mounted.
|
|
412
|
+
* Branch deletion remains best-effort. */
|
|
282
413
|
async function removeWorktree(git, input) {
|
|
283
|
-
const removed = await git.run(['worktree', 'remove', '--force', input.worktreePath], input.repoDir);
|
|
414
|
+
const removed = await git.run(['worktree', 'remove', ...(input.force === false ? [] : ['--force']), input.worktreePath], input.repoDir);
|
|
284
415
|
if (removed.code !== 0) {
|
|
285
416
|
throw new Error(`git worktree remove failed for ${input.worktreePath}: ${gitFailure(removed, `exit ${removed.code}`)}`);
|
|
286
417
|
}
|