specrails-desktop 2.29.2 → 2.29.3
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-CFB4UBhT.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-C-T0r16B.js} +1 -1
- package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-IljV5FOQ.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-B5miH10_.js} +1 -1
- package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-BgYQrAcH.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BtVCuNjL.js} +1 -1
- package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Cb_jAjSV.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-s_Cc_JL1.js} +1 -1
- package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-Cr47QI9L.js} +1 -1
- package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-C_WPRuG_.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-NfFOciRB.js} +1 -1
- package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DqEW7Z7K.js} +1 -1
- package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-D4ppEpDE.js} +1 -1
- package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-DStSoMyq.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-DltCwS4P.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-BWaiAbUq.js} +1 -1
- package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-BqjbXuBZ.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext--o3JAM9F.js} +1 -1
- package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-B-UOmzXw.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-DkGajIxE.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-5CUXlkw7.js} +1 -1
- package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-B_igqmti.js} +4 -4
- package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-DDMIBBUd.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-BYzxom0L.js +1 -0
- package/client/dist/assets/dashboard-BmDZmHyY.js +1 -0
- package/client/dist/assets/dashboard-CnNtJBQ5.js +1 -0
- package/client/dist/assets/dashboard-D4Uj-XWc2.js +1 -0
- package/client/dist/assets/dashboard-D57uRaGw2.js +1 -0
- package/client/dist/assets/dashboard-DAr6F9et.js +1 -0
- package/client/dist/assets/dashboard-vAcsn0L3.js +1 -0
- package/client/dist/assets/dashboard-vrjZvxWy.js +1 -0
- package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-BCUhGUXS.js} +1 -1
- package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-DcoXTnEI.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-DP3Ab6fX.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DdKKL8Fx.js} +1 -1
- package/client/dist/assets/{i18n-CgaRxpII.js → i18n-DREQCE2F.js} +2 -2
- package/client/dist/assets/index-B784m8RT.js +76 -0
- package/client/dist/assets/index-CQBIemiz.css +2 -0
- package/client/dist/assets/{spending-C4p06nen.js → spending-CtY7yYes.js} +1 -1
- package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-BFBdW59q.js} +1 -1
- package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-uWVZ8eia.js} +1 -1
- package/client/dist/index.html +14 -14
- package/docs/internals/safe-pr-review-flow.md +221 -38
- package/package.json +1 -1
- package/server/dist/active-pr-continuation.js +237 -52
- package/server/dist/agent-chat-manager.js +29 -12
- package/server/dist/agent-store.js +19 -4
- package/server/dist/db.js +21 -0
- package/server/dist/pr-lifecycle.js +100 -8
- package/server/dist/project-git.js +62 -2
- package/server/dist/rail-isolated-launch.js +644 -108
- package/server/dist/rail-pr-decision.js +1065 -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 +290 -4
- package/server/dist/rails-router.js +97 -9
- package/server/dist/worktree-manager.js +120 -16
- 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
|
@@ -1,13 +1,48 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.resolveActivePrContinuationTargets = resolveActivePrContinuationTargets;
|
|
4
37
|
const jira_db_1 = require("./jira/jira-db");
|
|
5
38
|
const rail_pr_store_1 = require("./rail-pr-store");
|
|
6
39
|
const integration_branch_1 = require("./integration-branch");
|
|
40
|
+
const rail_worktrees_store_1 = require("./rail-worktrees-store");
|
|
7
41
|
const pr_lifecycle_1 = require("./pr-lifecycle");
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
|
|
8
44
|
const PR_MATCH_RANK = {
|
|
9
|
-
'
|
|
10
|
-
'ticket-ref': 2,
|
|
45
|
+
'jira-key': 1,
|
|
11
46
|
'pr-number': 3,
|
|
12
47
|
};
|
|
13
48
|
function parsePrNumber(prUrl) {
|
|
@@ -16,28 +51,18 @@ function parsePrNumber(prUrl) {
|
|
|
16
51
|
const m = /\/pull\/(\d+)/.exec(prUrl);
|
|
17
52
|
return m ? parseInt(m[1], 10) : null;
|
|
18
53
|
}
|
|
19
|
-
function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return normalizedText(v)
|
|
24
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
25
|
-
.split(/\s+/)
|
|
26
|
-
.filter((w) => w.length >= 4);
|
|
27
|
-
}
|
|
28
|
-
function ticketRefs(db, ticketId, spec) {
|
|
29
|
-
const refs = new Set([`#${ticketId}`, `[${ticketId}]`, `ticket ${ticketId}`, `spec ${ticketId}`]);
|
|
30
|
-
let jiraKey = spec?.jira_key ?? null;
|
|
54
|
+
function jiraRefs(db, ticketId, spec) {
|
|
55
|
+
const refs = new Set();
|
|
56
|
+
if (spec?.jira_key?.trim())
|
|
57
|
+
refs.add(spec.jira_key.trim());
|
|
31
58
|
try {
|
|
32
59
|
const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
|
|
33
60
|
if (link && !link.tombstoned && link.jiraKey)
|
|
34
|
-
|
|
61
|
+
refs.add(link.jiraKey);
|
|
35
62
|
}
|
|
36
63
|
catch {
|
|
37
64
|
/* tolerated */
|
|
38
65
|
}
|
|
39
|
-
if (jiraKey?.trim())
|
|
40
|
-
refs.add(jiraKey.trim());
|
|
41
66
|
return [...refs];
|
|
42
67
|
}
|
|
43
68
|
function mentionedPrNumbers(spec) {
|
|
@@ -67,23 +92,13 @@ function prMatchKind(db, ticketId, spec, pr) {
|
|
|
67
92
|
const prNumber = typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url);
|
|
68
93
|
if (prNumber !== null && mentionedPrs.has(prNumber))
|
|
69
94
|
return 'pr-number';
|
|
70
|
-
const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}
|
|
71
|
-
for (const
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
return 'ticket-ref';
|
|
76
|
-
}
|
|
77
|
-
else if (new RegExp(`(^|[^a-z0-9])${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i').test(haystack)) {
|
|
78
|
-
return 'ticket-ref';
|
|
79
|
-
}
|
|
95
|
+
const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`;
|
|
96
|
+
for (const key of jiraRefs(db, ticketId, spec)) {
|
|
97
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
98
|
+
if (new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(haystack))
|
|
99
|
+
return 'jira-key';
|
|
80
100
|
}
|
|
81
|
-
|
|
82
|
-
if (titleWords.length < 3)
|
|
83
|
-
return null;
|
|
84
|
-
const prWords = new Set(words(`${pr.title ?? ''} ${pr.body ?? ''}`));
|
|
85
|
-
const hits = titleWords.filter((w) => prWords.has(w)).length;
|
|
86
|
-
return hits >= Math.max(3, Math.ceil(titleWords.length * 0.7)) ? 'title-similarity' : null;
|
|
101
|
+
return null;
|
|
87
102
|
}
|
|
88
103
|
function canProbeGithubForContinuation(db, ticketId, spec) {
|
|
89
104
|
if (spec?.status === 'on_review')
|
|
@@ -93,11 +108,6 @@ function canProbeGithubForContinuation(db, ticketId, spec) {
|
|
|
93
108
|
// Jira-linked ticket and later only accept explicit PR references.
|
|
94
109
|
return spec?.status === 'in_progress' && hasJiraRef(db, ticketId, spec);
|
|
95
110
|
}
|
|
96
|
-
function matchAllowedForTicket(spec, kind) {
|
|
97
|
-
if (spec?.status === 'on_review')
|
|
98
|
-
return true;
|
|
99
|
-
return kind !== 'title-similarity';
|
|
100
|
-
}
|
|
101
111
|
async function localBranchExists(git, repoDir, branch) {
|
|
102
112
|
const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoDir);
|
|
103
113
|
return r.code === 0;
|
|
@@ -106,7 +116,21 @@ async function remoteBranchExists(git, repoDir, branch) {
|
|
|
106
116
|
const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], repoDir);
|
|
107
117
|
return r.code === 0;
|
|
108
118
|
}
|
|
109
|
-
async function
|
|
119
|
+
async function observeRef(git, repoDir, ref) {
|
|
120
|
+
try {
|
|
121
|
+
const result = await git.run(['rev-parse', '--verify', '--quiet', ref], repoDir);
|
|
122
|
+
if (result.code === 1)
|
|
123
|
+
return { state: 'missing' };
|
|
124
|
+
if (result.code !== 0)
|
|
125
|
+
return { state: 'invalid' };
|
|
126
|
+
const sha = result.stdout.trim().toLowerCase();
|
|
127
|
+
return COMMIT_SHA_RE.test(sha) ? { state: 'exact', sha } : { state: 'invalid' };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { state: 'invalid' };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, deliverySha, deliveryId, source) {
|
|
110
134
|
const head = branch?.trim();
|
|
111
135
|
const base = baseBranch?.trim();
|
|
112
136
|
if (!head || !base || head === base)
|
|
@@ -115,35 +139,135 @@ async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBr
|
|
|
115
139
|
return null;
|
|
116
140
|
const remoteRef = fetchOk && await remoteBranchExists(git, repoDir, head) ? `origin/${head}` : undefined;
|
|
117
141
|
if (await localBranchExists(git, repoDir, head)) {
|
|
118
|
-
return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
|
|
142
|
+
return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
|
|
119
143
|
}
|
|
120
144
|
if (remoteRef)
|
|
121
|
-
return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
|
|
145
|
+
return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
|
|
122
146
|
return null;
|
|
123
147
|
}
|
|
124
|
-
function
|
|
148
|
+
function hasRetainedOrUnsettledWorktree(db, worktreeIds) {
|
|
149
|
+
return worktreeIds.some((id) => {
|
|
150
|
+
const worktree = (0, rail_worktrees_store_1.getRailWorktree)(db, id);
|
|
151
|
+
if (!worktree)
|
|
152
|
+
return false;
|
|
153
|
+
return !['released', 'merged', 'failed'].includes(worktree.merge_state) || fs.existsSync(worktree.worktree_path);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function internalDeliveryTargets(db, rows, ticketIds) {
|
|
125
157
|
const wanted = new Set(ticketIds);
|
|
126
158
|
const out = new Map();
|
|
127
|
-
for (const row of
|
|
159
|
+
for (const row of rows) {
|
|
128
160
|
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
129
161
|
if (!snap.prUrl || !snap.branch)
|
|
130
162
|
continue;
|
|
163
|
+
// A still-mounted or unsettled prior worktree can contain bytes not
|
|
164
|
+
// represented by delivery_sha. Never let the next agent iteration absorb
|
|
165
|
+
// them merely because HEAD/ref still happen to match the remote PR.
|
|
166
|
+
if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds))
|
|
167
|
+
continue;
|
|
131
168
|
for (const ticketId of snap.ticketIds) {
|
|
132
169
|
if (!wanted.has(ticketId) || out.has(ticketId))
|
|
133
170
|
continue;
|
|
134
171
|
out.set(ticketId, {
|
|
135
172
|
ticketId,
|
|
173
|
+
deliveryId: row.id,
|
|
136
174
|
branch: snap.branch,
|
|
137
175
|
baseBranch: snap.baseBranch,
|
|
138
176
|
prUrl: snap.prUrl,
|
|
139
177
|
prNumber: snap.prNumber,
|
|
140
178
|
isDraft: snap.decision === 'pr_draft',
|
|
179
|
+
deliverySha: snap.deliverySha,
|
|
141
180
|
source: 'rail-pr-delivery',
|
|
142
181
|
});
|
|
143
182
|
}
|
|
144
183
|
}
|
|
145
184
|
return out;
|
|
146
185
|
}
|
|
186
|
+
function ticketTargetKey(ticketIds) {
|
|
187
|
+
if (ticketIds.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
for (const value of ticketIds) {
|
|
191
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
|
|
192
|
+
seen.add(value);
|
|
193
|
+
}
|
|
194
|
+
return seen.size > 0 ? [...seen].sort((a, b) => a - b).join(',') : null;
|
|
195
|
+
}
|
|
196
|
+
function exactTicketTargetKey(ticketIds) {
|
|
197
|
+
const key = ticketTargetKey(ticketIds);
|
|
198
|
+
if (!key || key.split(',').length !== ticketIds.length)
|
|
199
|
+
return null;
|
|
200
|
+
return key;
|
|
201
|
+
}
|
|
202
|
+
/** Recover only the newest terminal generation for the exact launch target.
|
|
203
|
+
* A newer fresh/other generation intentionally prevents an older continuation
|
|
204
|
+
* from lending its PR. Of terminal continuations, only an explicitly discarded
|
|
205
|
+
* one can be resumed: merged/completed/superseded history remains history. */
|
|
206
|
+
function latestHistoricalContinuation(db, ticketIds) {
|
|
207
|
+
const targetKey = exactTicketTargetKey(ticketIds);
|
|
208
|
+
if (!targetKey)
|
|
209
|
+
return { ownsTarget: false, candidate: null };
|
|
210
|
+
const latest = (0, rail_pr_store_1.getLatestTerminalPrDeliveryTouchingTicketSet)(db, ticketIds);
|
|
211
|
+
if (!latest)
|
|
212
|
+
return { ownsTarget: false, candidate: null };
|
|
213
|
+
// The newest exact-target generation is authoritative even when it is a
|
|
214
|
+
// fresh lineage. It must shadow every older continuation and also prevent a
|
|
215
|
+
// fuzzy GitHub/Jira fallback from rediscovering that stale PR.
|
|
216
|
+
if (latest.is_continuation !== 1)
|
|
217
|
+
return { ownsTarget: true, candidate: null };
|
|
218
|
+
if (latest.decision !== 'discarded')
|
|
219
|
+
return { ownsTarget: true, candidate: null };
|
|
220
|
+
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(latest);
|
|
221
|
+
if (exactTicketTargetKey(snap.ticketIds) !== targetKey || latest.operation_token !== null) {
|
|
222
|
+
return { ownsTarget: true, candidate: null };
|
|
223
|
+
}
|
|
224
|
+
if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds)) {
|
|
225
|
+
return { ownsTarget: true, candidate: null };
|
|
226
|
+
}
|
|
227
|
+
const branch = snap.branch;
|
|
228
|
+
const baseBranch = snap.baseBranch;
|
|
229
|
+
const prUrl = snap.prUrl;
|
|
230
|
+
const deliverySha = snap.deliverySha?.toLowerCase() ?? null;
|
|
231
|
+
if (!branch || branch.trim() !== branch || !(0, integration_branch_1.isValidBranchName)(branch) ||
|
|
232
|
+
!baseBranch || baseBranch.trim() !== baseBranch || !(0, integration_branch_1.isValidBranchName)(baseBranch) ||
|
|
233
|
+
branch === baseBranch ||
|
|
234
|
+
!prUrl || prUrl.trim() !== prUrl ||
|
|
235
|
+
snap.prNumber === null || parsePrNumber(prUrl) !== snap.prNumber ||
|
|
236
|
+
!deliverySha || !COMMIT_SHA_RE.test(deliverySha))
|
|
237
|
+
return { ownsTarget: true, candidate: null };
|
|
238
|
+
return {
|
|
239
|
+
ownsTarget: true,
|
|
240
|
+
candidate: { deliveryId: latest.id, branch, baseBranch, prUrl, prNumber: snap.prNumber, deliverySha },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Historical recovery is stricter than ordinary active continuation
|
|
244
|
+
* materialization. Every available fetched/local ref must still name the exact
|
|
245
|
+
* immutable GitHub head; a stale, ahead, diverged, or unreadable ref fails
|
|
246
|
+
* closed rather than allowing worktree allocation to infer or rewrite it. */
|
|
247
|
+
async function materializeHistoricalContinuation(input, candidate, isDraft) {
|
|
248
|
+
const local = await observeRef(input.git, input.repoDir, `refs/heads/${candidate.branch}`);
|
|
249
|
+
if (local.state === 'invalid' || (local.state === 'exact' && local.sha !== candidate.deliverySha))
|
|
250
|
+
return null;
|
|
251
|
+
const remote = input.fetchOk
|
|
252
|
+
? await observeRef(input.git, input.repoDir, `refs/remotes/origin/${candidate.branch}`)
|
|
253
|
+
: { state: 'missing' };
|
|
254
|
+
if (remote.state === 'invalid' || (remote.state === 'exact' && remote.sha !== candidate.deliverySha))
|
|
255
|
+
return null;
|
|
256
|
+
if (local.state !== 'exact' && remote.state !== 'exact')
|
|
257
|
+
return null;
|
|
258
|
+
return new Map(input.ticketIds.map((ticketId) => [ticketId, {
|
|
259
|
+
ticketId,
|
|
260
|
+
deliveryId: candidate.deliveryId,
|
|
261
|
+
branch: candidate.branch,
|
|
262
|
+
baseBranch: candidate.baseBranch,
|
|
263
|
+
...(remote.state === 'exact' ? { baseRef: `origin/${candidate.branch}` } : {}),
|
|
264
|
+
prUrl: candidate.prUrl,
|
|
265
|
+
prNumber: candidate.prNumber,
|
|
266
|
+
isDraft,
|
|
267
|
+
deliverySha: candidate.deliverySha,
|
|
268
|
+
source: 'rail-pr-delivery',
|
|
269
|
+
}]));
|
|
270
|
+
}
|
|
147
271
|
const PR_JSON_FIELDS = 'number,title,body,headRefName,baseRefName,url,isDraft,state';
|
|
148
272
|
async function listOpenPrs(exec, repoDir) {
|
|
149
273
|
const r = await exec.run('gh', ['pr', 'list', '--state', 'open', '--limit', '200', '--json', PR_JSON_FIELDS], repoDir);
|
|
@@ -198,32 +322,77 @@ function mergeOpenPrs(prs) {
|
|
|
198
322
|
/**
|
|
199
323
|
* Detects tickets whose next implementation pass should continue an already
|
|
200
324
|
* open PR branch instead of creating fresh work from the integration branch.
|
|
201
|
-
*
|
|
202
|
-
*
|
|
325
|
+
* External discovery is fail-open when no durable Specrails lineage exists.
|
|
326
|
+
* Once an active or newest overlapping historical generation owns the target,
|
|
327
|
+
* however, missing/ambiguous GitHub or ref evidence is authoritative negative
|
|
328
|
+
* evidence and blocks fuzzy fallback to a different PR.
|
|
203
329
|
*/
|
|
204
330
|
async function resolveActivePrContinuationTargets(input) {
|
|
205
331
|
const out = new Map();
|
|
206
|
-
const
|
|
332
|
+
const activeRows = (0, rail_pr_store_1.listActivePrDeliveries)(input.db);
|
|
333
|
+
const internal = internalDeliveryTargets(input.db, activeRows, input.ticketIds);
|
|
207
334
|
const lifecycleByUrl = new Map();
|
|
208
335
|
for (const [ticketId, target] of internal) {
|
|
209
336
|
if (!target.prUrl)
|
|
210
337
|
continue;
|
|
211
338
|
let lifecycle = lifecycleByUrl.get(target.prUrl);
|
|
212
339
|
if (!lifecycle) {
|
|
213
|
-
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl);
|
|
340
|
+
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl, target.deliverySha);
|
|
214
341
|
lifecycleByUrl.set(target.prUrl, lifecycle);
|
|
215
342
|
}
|
|
216
343
|
const observed = await lifecycle;
|
|
217
344
|
// A ledger row is only a hint. GitHub remains lifecycle authority: a stale
|
|
218
345
|
// draft/ready row for a CLOSED/MERGED or different head/base PR must not be
|
|
219
346
|
// superseded and must not lend its branch to another implementation run.
|
|
220
|
-
if (!
|
|
347
|
+
if (!target.deliverySha || !observed.ok ||
|
|
348
|
+
!(0, pr_lifecycle_1.isExactOpenPr)(observed, target.branch, target.baseBranch) ||
|
|
349
|
+
observed.includesExpectedSha !== true ||
|
|
350
|
+
observed.headRefOid?.toLowerCase() !== target.deliverySha.toLowerCase())
|
|
221
351
|
continue;
|
|
222
|
-
const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, observed.isDraft, 'rail-pr-delivery');
|
|
352
|
+
const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, observed.isDraft, target.deliverySha, target.deliveryId, 'rail-pr-delivery');
|
|
223
353
|
if (materialized)
|
|
224
354
|
out.set(ticketId, materialized);
|
|
225
355
|
}
|
|
226
|
-
|
|
356
|
+
// Dismiss/Discard closes the continuation generation but deliberately keeps
|
|
357
|
+
// its borrowed PR alive. Recover that exact target only when no active row
|
|
358
|
+
// owns any requested ticket and the latest terminal generation for the whole
|
|
359
|
+
// target is the discarded continuation. Failed historical verification is
|
|
360
|
+
// still authoritative negative evidence: do not fall through to fuzzy PR
|
|
361
|
+
// discovery and accidentally borrow a same-Jira foreign PR.
|
|
362
|
+
const wanted = new Set(input.ticketIds);
|
|
363
|
+
const activeTouchesTarget = activeRows.some((row) => ((0, rail_pr_store_1.toPrDeliverySnapshot)(row).ticketIds.some((ticketId) => wanted.has(ticketId))));
|
|
364
|
+
const historicalOwned = new Set();
|
|
365
|
+
const activeOwned = new Set();
|
|
366
|
+
if (activeTouchesTarget)
|
|
367
|
+
input.ticketIds.forEach((ticketId) => activeOwned.add(ticketId));
|
|
368
|
+
if (!activeTouchesTarget && out.size === 0) {
|
|
369
|
+
const historical = latestHistoricalContinuation(input.db, input.ticketIds);
|
|
370
|
+
if (historical.ownsTarget)
|
|
371
|
+
input.ticketIds.forEach((ticketId) => historicalOwned.add(ticketId));
|
|
372
|
+
if (historical.candidate) {
|
|
373
|
+
const candidate = historical.candidate;
|
|
374
|
+
let lifecycle = lifecycleByUrl.get(candidate.prUrl);
|
|
375
|
+
if (!lifecycle) {
|
|
376
|
+
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, candidate.prUrl, candidate.deliverySha);
|
|
377
|
+
lifecycleByUrl.set(candidate.prUrl, lifecycle);
|
|
378
|
+
}
|
|
379
|
+
const observed = await lifecycle;
|
|
380
|
+
if (observed.ok &&
|
|
381
|
+
(0, pr_lifecycle_1.isExactOpenPr)(observed, candidate.branch, candidate.baseBranch) &&
|
|
382
|
+
observed.headRefOid?.toLowerCase() === candidate.deliverySha &&
|
|
383
|
+
observed.includesExpectedSha === true) {
|
|
384
|
+
const materialized = await materializeHistoricalContinuation(input, candidate, observed.isDraft);
|
|
385
|
+
if (materialized) {
|
|
386
|
+
for (const [ticketId, target] of materialized)
|
|
387
|
+
out.set(ticketId, target);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// A durable Specrails delivery that fails exact verification must never be
|
|
393
|
+
// downgraded into an inferred "external" match for the same ticket/PR. The
|
|
394
|
+
// visible delivery card owns reconciliation of that stale immutable SHA.
|
|
395
|
+
const remaining = input.ticketIds.filter((id) => (!out.has(id) && !internal.has(id) && !historicalOwned.has(id) && !activeOwned.has(id)));
|
|
227
396
|
const candidateIds = remaining.filter((id) => canProbeGithubForContinuation(input.db, id, input.getTicketSpec(id)));
|
|
228
397
|
if (candidateIds.length === 0)
|
|
229
398
|
return out;
|
|
@@ -240,7 +409,7 @@ async function resolveActivePrContinuationTargets(input) {
|
|
|
240
409
|
const spec = input.getTicketSpec(ticketId);
|
|
241
410
|
const matches = openPrs.flatMap((pr) => {
|
|
242
411
|
const kind = prMatchKind(input.db, ticketId, spec, pr);
|
|
243
|
-
return kind
|
|
412
|
+
return kind ? [{ pr, kind }] : [];
|
|
244
413
|
});
|
|
245
414
|
if (matches.length === 0)
|
|
246
415
|
continue;
|
|
@@ -249,7 +418,23 @@ async function resolveActivePrContinuationTargets(input) {
|
|
|
249
418
|
if (best.length !== 1)
|
|
250
419
|
continue;
|
|
251
420
|
const pr = best[0].pr;
|
|
252
|
-
const
|
|
421
|
+
const prUrl = pr.url?.trim();
|
|
422
|
+
const branch = pr.headRefName?.trim();
|
|
423
|
+
const baseBranch = pr.baseRefName?.trim() || input.integrationBranch;
|
|
424
|
+
if (!prUrl || !branch || !baseBranch)
|
|
425
|
+
continue;
|
|
426
|
+
let lifecycle = lifecycleByUrl.get(prUrl);
|
|
427
|
+
if (!lifecycle) {
|
|
428
|
+
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, prUrl);
|
|
429
|
+
lifecycleByUrl.set(prUrl, lifecycle);
|
|
430
|
+
}
|
|
431
|
+
const observed = await lifecycle;
|
|
432
|
+
// `gh pr list` is discovery evidence only. Freeze the exact live remote
|
|
433
|
+
// head through an authoritative view before borrowing any local branch;
|
|
434
|
+
// otherwise an unrelated local-ahead commit could be carried into the PR.
|
|
435
|
+
if (!observed.ok || !observed.headRefOid || !(0, pr_lifecycle_1.isExactOpenPr)(observed, branch, baseBranch))
|
|
436
|
+
continue;
|
|
437
|
+
const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, branch, baseBranch, prUrl, typeof pr.number === 'number' ? pr.number : parsePrNumber(prUrl), observed.isDraft, observed.headRefOid, null, 'github-open-pr');
|
|
253
438
|
if (materialized)
|
|
254
439
|
out.set(ticketId, materialized);
|
|
255
440
|
}
|
|
@@ -696,6 +696,11 @@ class AgentChatManager {
|
|
|
696
696
|
console.log(`[agent-chat] pr-decision card skipped — conversation gone (${conversationId})`);
|
|
697
697
|
return;
|
|
698
698
|
}
|
|
699
|
+
const existing = this._findPrDecisionCards(conversationId, envelope.prDeliveryId);
|
|
700
|
+
if (existing.length > 0) {
|
|
701
|
+
this.updatePrDecisionCard(conversationId, envelope);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
699
704
|
(0, agent_store_1.addAgentMessage)(this._db, { conversationId, role: 'system', content: JSON.stringify(envelope) });
|
|
700
705
|
this._broadcastPrDecision(conversationId, envelope);
|
|
701
706
|
}
|
|
@@ -714,33 +719,45 @@ class AgentChatManager {
|
|
|
714
719
|
if (this._disposed)
|
|
715
720
|
return;
|
|
716
721
|
try {
|
|
717
|
-
const existing =
|
|
718
|
-
|
|
719
|
-
const parsed = JSON.parse(content);
|
|
720
|
-
return parsed.kind === 'pr_decision' && parsed.prDeliveryId === envelope.prDeliveryId;
|
|
721
|
-
}
|
|
722
|
-
catch {
|
|
723
|
-
return false;
|
|
724
|
-
}
|
|
725
|
-
});
|
|
726
|
-
if (!existing) {
|
|
722
|
+
const existing = this._findPrDecisionCards(conversationId, envelope.prDeliveryId);
|
|
723
|
+
if (existing.length === 0) {
|
|
727
724
|
this.postPrDecisionCard(conversationId, envelope);
|
|
728
725
|
return;
|
|
729
726
|
}
|
|
730
727
|
const serialized = JSON.stringify(envelope);
|
|
728
|
+
// Keep the newest history anchor: client cold-load dedupe uses the same
|
|
729
|
+
// newest-row rule, so a crash between hydration and this repair cannot
|
|
730
|
+
// make an older blocked envelope authoritative again.
|
|
731
|
+
const canonical = existing[existing.length - 1];
|
|
732
|
+
const duplicateIds = existing.slice(0, -1).map((message) => message.id);
|
|
731
733
|
// Startup recovery may inspect historical deliveries to heal a card that
|
|
732
734
|
// missed its terminal update. Identical projection is not new activity:
|
|
733
735
|
// avoid a redundant DB write and, crucially, do not broadcast an unread
|
|
734
736
|
// event for an old card on every app launch.
|
|
735
|
-
if (
|
|
737
|
+
if (canonical.content === serialized && duplicateIds.length === 0)
|
|
736
738
|
return;
|
|
737
|
-
|
|
739
|
+
this._db.transaction(() => {
|
|
740
|
+
if (canonical.content !== serialized)
|
|
741
|
+
(0, agent_store_1.updateAgentMessageContent)(this._db, canonical.id, serialized);
|
|
742
|
+
(0, agent_store_1.deleteAgentMessagesByIds)(this._db, duplicateIds);
|
|
743
|
+
})();
|
|
738
744
|
this._broadcastPrDecision(conversationId, envelope);
|
|
739
745
|
}
|
|
740
746
|
catch (err) {
|
|
741
747
|
console.error(`[agent-chat] updatePrDecisionCard failed (${conversationId}):`, err);
|
|
742
748
|
}
|
|
743
749
|
}
|
|
750
|
+
_findPrDecisionCards(conversationId, prDeliveryId) {
|
|
751
|
+
return (0, agent_store_1.findAgentSystemMessages)(this._db, conversationId, (content) => {
|
|
752
|
+
try {
|
|
753
|
+
const parsed = JSON.parse(content);
|
|
754
|
+
return parsed.kind === 'pr_decision' && parsed.prDeliveryId === prDeliveryId;
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}
|
|
744
761
|
_broadcastPrDecision(conversationId, envelope) {
|
|
745
762
|
this._broadcast({
|
|
746
763
|
type: 'agent_pr_decision',
|
|
@@ -7,7 +7,9 @@ exports.updateAgentConversation = updateAgentConversation;
|
|
|
7
7
|
exports.deleteAgentConversation = deleteAgentConversation;
|
|
8
8
|
exports.addAgentMessage = addAgentMessage;
|
|
9
9
|
exports.listAgentMessages = listAgentMessages;
|
|
10
|
+
exports.findAgentSystemMessages = findAgentSystemMessages;
|
|
10
11
|
exports.findAgentSystemMessage = findAgentSystemMessage;
|
|
12
|
+
exports.deleteAgentMessagesByIds = deleteAgentMessagesByIds;
|
|
11
13
|
exports.updateAgentMessageContent = updateAgentMessageContent;
|
|
12
14
|
const crypto_1 = require("crypto");
|
|
13
15
|
const agent_tier_1 = require("./agent-tier");
|
|
@@ -94,16 +96,29 @@ function listAgentMessages(db, conversationId) {
|
|
|
94
96
|
.all(conversationId).map(mapMessage);
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
97
|
-
*
|
|
99
|
+
* Every `system`-role message in a conversation whose content satisfies
|
|
98
100
|
* `predicate` (oldest first). The store stays payload-agnostic — callers own
|
|
99
101
|
* the content parsing (e.g. matching a PR-decision card by prDeliveryId).
|
|
100
102
|
*/
|
|
101
|
-
function
|
|
103
|
+
function findAgentSystemMessages(db, conversationId, predicate = () => true) {
|
|
102
104
|
const rows = db
|
|
103
105
|
.prepare("SELECT * FROM agent_messages WHERE conversation_id = ? AND role = 'system' ORDER BY created_at ASC, rowid ASC")
|
|
104
106
|
.all(conversationId);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
return rows.filter((row) => predicate(row.content)).map(mapMessage);
|
|
108
|
+
}
|
|
109
|
+
function findAgentSystemMessage(db, conversationId, predicate = () => true) {
|
|
110
|
+
return findAgentSystemMessages(db, conversationId, predicate)[0];
|
|
111
|
+
}
|
|
112
|
+
/** Delete exact message ids without bumping conversation activity. Callers use
|
|
113
|
+
* this only to consolidate duplicate derived system cards, never user turns. */
|
|
114
|
+
function deleteAgentMessagesByIds(db, messageIds) {
|
|
115
|
+
if (messageIds.length === 0)
|
|
116
|
+
return 0;
|
|
117
|
+
const remove = db.prepare('DELETE FROM agent_messages WHERE id = ?');
|
|
118
|
+
let deleted = 0;
|
|
119
|
+
for (const id of messageIds)
|
|
120
|
+
deleted += remove.run(id).changes;
|
|
121
|
+
return deleted;
|
|
107
122
|
}
|
|
108
123
|
/**
|
|
109
124
|
* Replace a message's content in place (inline-card state updates). Does NOT
|
package/server/dist/db.js
CHANGED
|
@@ -1405,6 +1405,27 @@ const MIGRATIONS = [
|
|
|
1405
1405
|
}
|
|
1406
1406
|
backfillTerminalRailPrTicketEffects(db);
|
|
1407
1407
|
},
|
|
1408
|
+
// Migration 53: an allocation rollback is the one legal way a previously
|
|
1409
|
+
// superseded delivery can become active again. Persist the failed replacement
|
|
1410
|
+
// id on the restored predecessor so clients can distinguish that explicit
|
|
1411
|
+
// rollback from an ordinary stale non-terminal replay.
|
|
1412
|
+
(db) => {
|
|
1413
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
|
|
1414
|
+
.map((row) => row.name));
|
|
1415
|
+
if (!cols.has('restored_from_delivery_id')) {
|
|
1416
|
+
db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN restored_from_delivery_id TEXT`);
|
|
1417
|
+
}
|
|
1418
|
+
},
|
|
1419
|
+
// Migration 54: automatic worktree release quarantines authenticated overlay
|
|
1420
|
+
// roots instead of deleting them. Persist the bounded archive locations so
|
|
1421
|
+
// recovery remains discoverable after restart and card refresh.
|
|
1422
|
+
(db) => {
|
|
1423
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
|
|
1424
|
+
.map((row) => row.name));
|
|
1425
|
+
if (!cols.has('safety_archives')) {
|
|
1426
|
+
db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN safety_archives TEXT NOT NULL DEFAULT '[]'`);
|
|
1427
|
+
}
|
|
1428
|
+
},
|
|
1408
1429
|
];
|
|
1409
1430
|
function applyMigrations(db) {
|
|
1410
1431
|
// Ensure the migrations table exists (migration 1 creates it, but we need
|