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
|
@@ -1,13 +1,60 @@
|
|
|
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 });
|
|
36
|
+
exports.ExplicitPrTargetError = void 0;
|
|
37
|
+
exports.resolveExplicitPrTarget = resolveExplicitPrTarget;
|
|
38
|
+
exports.listPrCandidatesForTickets = listPrCandidatesForTickets;
|
|
3
39
|
exports.resolveActivePrContinuationTargets = resolveActivePrContinuationTargets;
|
|
4
40
|
const jira_db_1 = require("./jira/jira-db");
|
|
5
41
|
const rail_pr_store_1 = require("./rail-pr-store");
|
|
6
42
|
const integration_branch_1 = require("./integration-branch");
|
|
43
|
+
const rail_worktrees_store_1 = require("./rail-worktrees-store");
|
|
7
44
|
const pr_lifecycle_1 = require("./pr-lifecycle");
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
class ExplicitPrTargetError extends Error {
|
|
47
|
+
code;
|
|
48
|
+
constructor(code, message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'ExplicitPrTargetError';
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.ExplicitPrTargetError = ExplicitPrTargetError;
|
|
55
|
+
const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
|
|
8
56
|
const PR_MATCH_RANK = {
|
|
9
|
-
'
|
|
10
|
-
'ticket-ref': 2,
|
|
57
|
+
'jira-key': 1,
|
|
11
58
|
'pr-number': 3,
|
|
12
59
|
};
|
|
13
60
|
function parsePrNumber(prUrl) {
|
|
@@ -16,28 +63,18 @@ function parsePrNumber(prUrl) {
|
|
|
16
63
|
const m = /\/pull\/(\d+)/.exec(prUrl);
|
|
17
64
|
return m ? parseInt(m[1], 10) : null;
|
|
18
65
|
}
|
|
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;
|
|
66
|
+
function jiraRefs(db, ticketId, spec) {
|
|
67
|
+
const refs = new Set();
|
|
68
|
+
if (spec?.jira_key?.trim())
|
|
69
|
+
refs.add(spec.jira_key.trim());
|
|
31
70
|
try {
|
|
32
71
|
const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
|
|
33
72
|
if (link && !link.tombstoned && link.jiraKey)
|
|
34
|
-
|
|
73
|
+
refs.add(link.jiraKey);
|
|
35
74
|
}
|
|
36
75
|
catch {
|
|
37
76
|
/* tolerated */
|
|
38
77
|
}
|
|
39
|
-
if (jiraKey?.trim())
|
|
40
|
-
refs.add(jiraKey.trim());
|
|
41
78
|
return [...refs];
|
|
42
79
|
}
|
|
43
80
|
function mentionedPrNumbers(spec) {
|
|
@@ -67,23 +104,13 @@ function prMatchKind(db, ticketId, spec, pr) {
|
|
|
67
104
|
const prNumber = typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url);
|
|
68
105
|
if (prNumber !== null && mentionedPrs.has(prNumber))
|
|
69
106
|
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
|
-
}
|
|
107
|
+
const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`;
|
|
108
|
+
for (const key of jiraRefs(db, ticketId, spec)) {
|
|
109
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
110
|
+
if (new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(haystack))
|
|
111
|
+
return 'jira-key';
|
|
80
112
|
}
|
|
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;
|
|
113
|
+
return null;
|
|
87
114
|
}
|
|
88
115
|
function canProbeGithubForContinuation(db, ticketId, spec) {
|
|
89
116
|
if (spec?.status === 'on_review')
|
|
@@ -93,11 +120,6 @@ function canProbeGithubForContinuation(db, ticketId, spec) {
|
|
|
93
120
|
// Jira-linked ticket and later only accept explicit PR references.
|
|
94
121
|
return spec?.status === 'in_progress' && hasJiraRef(db, ticketId, spec);
|
|
95
122
|
}
|
|
96
|
-
function matchAllowedForTicket(spec, kind) {
|
|
97
|
-
if (spec?.status === 'on_review')
|
|
98
|
-
return true;
|
|
99
|
-
return kind !== 'title-similarity';
|
|
100
|
-
}
|
|
101
123
|
async function localBranchExists(git, repoDir, branch) {
|
|
102
124
|
const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoDir);
|
|
103
125
|
return r.code === 0;
|
|
@@ -106,7 +128,21 @@ async function remoteBranchExists(git, repoDir, branch) {
|
|
|
106
128
|
const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], repoDir);
|
|
107
129
|
return r.code === 0;
|
|
108
130
|
}
|
|
109
|
-
async function
|
|
131
|
+
async function observeRef(git, repoDir, ref) {
|
|
132
|
+
try {
|
|
133
|
+
const result = await git.run(['rev-parse', '--verify', '--quiet', ref], repoDir);
|
|
134
|
+
if (result.code === 1)
|
|
135
|
+
return { state: 'missing' };
|
|
136
|
+
if (result.code !== 0)
|
|
137
|
+
return { state: 'invalid' };
|
|
138
|
+
const sha = result.stdout.trim().toLowerCase();
|
|
139
|
+
return COMMIT_SHA_RE.test(sha) ? { state: 'exact', sha } : { state: 'invalid' };
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { state: 'invalid' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, deliverySha, deliveryId, source) {
|
|
110
146
|
const head = branch?.trim();
|
|
111
147
|
const base = baseBranch?.trim();
|
|
112
148
|
if (!head || !base || head === base)
|
|
@@ -115,36 +151,198 @@ async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBr
|
|
|
115
151
|
return null;
|
|
116
152
|
const remoteRef = fetchOk && await remoteBranchExists(git, repoDir, head) ? `origin/${head}` : undefined;
|
|
117
153
|
if (await localBranchExists(git, repoDir, head)) {
|
|
118
|
-
return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
|
|
154
|
+
return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
|
|
119
155
|
}
|
|
120
156
|
if (remoteRef)
|
|
121
|
-
return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
|
|
157
|
+
return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
|
|
122
158
|
return null;
|
|
123
159
|
}
|
|
124
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Resolve an EXPLICITLY user-designated PR number into a continuation target.
|
|
162
|
+
* This is the user's answer, not a discovery guess: it bypasses the automatic
|
|
163
|
+
* inference gates (`canProbeGithubForContinuation`, spec-text matching) but
|
|
164
|
+
* keeps the same authoritative validation ladder — the PR must be verifiably
|
|
165
|
+
* OPEN on the SAME repository with a materializable head. Every failure throws
|
|
166
|
+
* `ExplicitPrTargetError` with a distinct code; callers reject the launch
|
|
167
|
+
* before any delivery row or worktree exists.
|
|
168
|
+
*/
|
|
169
|
+
async function resolveExplicitPrTarget(input) {
|
|
170
|
+
const { prNumber } = input;
|
|
171
|
+
// 1. Resolve the PR number to its canonical URL (identity for the delivery
|
|
172
|
+
// row and for every later lifecycle observation / poll-merge).
|
|
173
|
+
let lookup;
|
|
174
|
+
try {
|
|
175
|
+
lookup = await input.exec.run('gh', ['pr', 'view', String(prNumber), '--json', 'url,number'], input.repoDir);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
throw new ExplicitPrTargetError('target_pr_not_found', `PR #${prNumber} could not be looked up: ${(err instanceof Error ? err.message : String(err)).slice(0, 256)}`);
|
|
179
|
+
}
|
|
180
|
+
if (lookup.code !== 0) {
|
|
181
|
+
const detail = (lookup.stderr.trim() || lookup.stdout.trim()).split('\n')[0].slice(0, 256);
|
|
182
|
+
throw new ExplicitPrTargetError('target_pr_not_found', `PR #${prNumber} could not be looked up: ${detail || `gh exited ${lookup.code}`}`);
|
|
183
|
+
}
|
|
184
|
+
let prUrl;
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(lookup.stdout);
|
|
187
|
+
if (typeof parsed.url !== 'string' || !parsed.url.trim() || parsed.number !== prNumber) {
|
|
188
|
+
throw new Error('unexpected gh payload');
|
|
189
|
+
}
|
|
190
|
+
prUrl = parsed.url.trim();
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
throw new ExplicitPrTargetError('target_pr_invalid', `GitHub returned an unusable identity for PR #${prNumber}`);
|
|
194
|
+
}
|
|
195
|
+
// 2. Authoritative lifecycle observation — state, fork boundary, exact head.
|
|
196
|
+
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, prUrl);
|
|
197
|
+
if (!observed.ok) {
|
|
198
|
+
throw new ExplicitPrTargetError('target_pr_not_found', `PR #${prNumber} lifecycle could not be confirmed: ${observed.detail}`);
|
|
199
|
+
}
|
|
200
|
+
if (observed.state !== 'OPEN') {
|
|
201
|
+
throw new ExplicitPrTargetError('target_pr_not_open', `PR #${prNumber} is ${observed.state.toLowerCase()}, not open`);
|
|
202
|
+
}
|
|
203
|
+
// `null` means GitHub did not prove the head belongs to this repository —
|
|
204
|
+
// conservative rejection: a fork head is not pushable with origin rights.
|
|
205
|
+
if (observed.isCrossRepository !== false) {
|
|
206
|
+
throw new ExplicitPrTargetError('target_pr_fork', `PR #${prNumber} head lives on a fork (or same-repo could not be proven); fork PRs are not pushable`);
|
|
207
|
+
}
|
|
208
|
+
const head = observed.headRefName?.trim();
|
|
209
|
+
const base = observed.baseRefName?.trim() || input.integrationBranch;
|
|
210
|
+
if (!head || !base || head === base || !(0, integration_branch_1.isValidBranchName)(head) || !(0, integration_branch_1.isValidBranchName)(base) || !observed.headRefOid) {
|
|
211
|
+
throw new ExplicitPrTargetError('target_pr_invalid', `PR #${prNumber} head/base refs are unusable (head=${head ?? '?'}, base=${base ?? '?'})`);
|
|
212
|
+
}
|
|
213
|
+
// 3. Materialize exactly like automatic continuations: the head branch must
|
|
214
|
+
// exist locally or be fetchable from origin; the worktree verification
|
|
215
|
+
// later pins it to the observed head OID.
|
|
216
|
+
const target = await materializeTarget(input.git, input.repoDir, input.fetchOk, 0, head, base, prUrl, prNumber, observed.isDraft, observed.headRefOid, null, 'explicit-target');
|
|
217
|
+
if (!target) {
|
|
218
|
+
throw new ExplicitPrTargetError('target_pr_unfetchable', `PR #${prNumber} head branch ${head} is neither present locally nor fetchable from origin`);
|
|
219
|
+
}
|
|
220
|
+
return target;
|
|
221
|
+
}
|
|
222
|
+
function hasRetainedOrUnsettledWorktree(db, worktreeIds) {
|
|
223
|
+
return worktreeIds.some((id) => {
|
|
224
|
+
const worktree = (0, rail_worktrees_store_1.getRailWorktree)(db, id);
|
|
225
|
+
if (!worktree)
|
|
226
|
+
return false;
|
|
227
|
+
return !['released', 'merged', 'failed'].includes(worktree.merge_state) || fs.existsSync(worktree.worktree_path);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function internalDeliveryTargets(db, rows, ticketIds) {
|
|
125
231
|
const wanted = new Set(ticketIds);
|
|
126
232
|
const out = new Map();
|
|
127
|
-
for (const row of
|
|
233
|
+
for (const row of rows) {
|
|
128
234
|
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
129
235
|
if (!snap.prUrl || !snap.branch)
|
|
130
236
|
continue;
|
|
237
|
+
// A still-mounted or unsettled prior worktree can contain bytes not
|
|
238
|
+
// represented by delivery_sha. Never let the next agent iteration absorb
|
|
239
|
+
// them merely because HEAD/ref still happen to match the remote PR.
|
|
240
|
+
if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds))
|
|
241
|
+
continue;
|
|
131
242
|
for (const ticketId of snap.ticketIds) {
|
|
132
243
|
if (!wanted.has(ticketId) || out.has(ticketId))
|
|
133
244
|
continue;
|
|
134
245
|
out.set(ticketId, {
|
|
135
246
|
ticketId,
|
|
247
|
+
deliveryId: row.id,
|
|
136
248
|
branch: snap.branch,
|
|
137
249
|
baseBranch: snap.baseBranch,
|
|
138
250
|
prUrl: snap.prUrl,
|
|
139
251
|
prNumber: snap.prNumber,
|
|
140
252
|
isDraft: snap.decision === 'pr_draft',
|
|
253
|
+
deliverySha: snap.deliverySha,
|
|
141
254
|
source: 'rail-pr-delivery',
|
|
142
255
|
});
|
|
143
256
|
}
|
|
144
257
|
}
|
|
145
258
|
return out;
|
|
146
259
|
}
|
|
147
|
-
|
|
260
|
+
function ticketTargetKey(ticketIds) {
|
|
261
|
+
if (ticketIds.length === 0)
|
|
262
|
+
return null;
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
for (const value of ticketIds) {
|
|
265
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
|
|
266
|
+
seen.add(value);
|
|
267
|
+
}
|
|
268
|
+
return seen.size > 0 ? [...seen].sort((a, b) => a - b).join(',') : null;
|
|
269
|
+
}
|
|
270
|
+
function exactTicketTargetKey(ticketIds) {
|
|
271
|
+
const key = ticketTargetKey(ticketIds);
|
|
272
|
+
if (!key || key.split(',').length !== ticketIds.length)
|
|
273
|
+
return null;
|
|
274
|
+
return key;
|
|
275
|
+
}
|
|
276
|
+
/** Recover only the newest terminal generation for the exact launch target.
|
|
277
|
+
* A newer fresh/other generation intentionally prevents an older continuation
|
|
278
|
+
* from lending its PR. Of terminal continuations, only an explicitly discarded
|
|
279
|
+
* one can be resumed: merged/completed/superseded history remains history. */
|
|
280
|
+
function latestHistoricalContinuation(db, ticketIds) {
|
|
281
|
+
const targetKey = exactTicketTargetKey(ticketIds);
|
|
282
|
+
if (!targetKey)
|
|
283
|
+
return { ownsTarget: false, candidate: null };
|
|
284
|
+
const latest = (0, rail_pr_store_1.getLatestTerminalPrDeliveryTouchingTicketSet)(db, ticketIds);
|
|
285
|
+
if (!latest)
|
|
286
|
+
return { ownsTarget: false, candidate: null };
|
|
287
|
+
// The newest exact-target generation is authoritative even when it is a
|
|
288
|
+
// fresh lineage. It must shadow every older continuation and also prevent a
|
|
289
|
+
// fuzzy GitHub/Jira fallback from rediscovering that stale PR.
|
|
290
|
+
if (latest.is_continuation !== 1)
|
|
291
|
+
return { ownsTarget: true, candidate: null };
|
|
292
|
+
if (latest.decision !== 'discarded')
|
|
293
|
+
return { ownsTarget: true, candidate: null };
|
|
294
|
+
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(latest);
|
|
295
|
+
if (exactTicketTargetKey(snap.ticketIds) !== targetKey || latest.operation_token !== null) {
|
|
296
|
+
return { ownsTarget: true, candidate: null };
|
|
297
|
+
}
|
|
298
|
+
if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds)) {
|
|
299
|
+
return { ownsTarget: true, candidate: null };
|
|
300
|
+
}
|
|
301
|
+
const branch = snap.branch;
|
|
302
|
+
const baseBranch = snap.baseBranch;
|
|
303
|
+
const prUrl = snap.prUrl;
|
|
304
|
+
const deliverySha = snap.deliverySha?.toLowerCase() ?? null;
|
|
305
|
+
if (!branch || branch.trim() !== branch || !(0, integration_branch_1.isValidBranchName)(branch) ||
|
|
306
|
+
!baseBranch || baseBranch.trim() !== baseBranch || !(0, integration_branch_1.isValidBranchName)(baseBranch) ||
|
|
307
|
+
branch === baseBranch ||
|
|
308
|
+
!prUrl || prUrl.trim() !== prUrl ||
|
|
309
|
+
snap.prNumber === null || parsePrNumber(prUrl) !== snap.prNumber ||
|
|
310
|
+
!deliverySha || !COMMIT_SHA_RE.test(deliverySha))
|
|
311
|
+
return { ownsTarget: true, candidate: null };
|
|
312
|
+
return {
|
|
313
|
+
ownsTarget: true,
|
|
314
|
+
candidate: { deliveryId: latest.id, branch, baseBranch, prUrl, prNumber: snap.prNumber, deliverySha },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** Historical recovery is stricter than ordinary active continuation
|
|
318
|
+
* materialization. Every available fetched/local ref must still name the exact
|
|
319
|
+
* immutable GitHub head; a stale, ahead, diverged, or unreadable ref fails
|
|
320
|
+
* closed rather than allowing worktree allocation to infer or rewrite it. */
|
|
321
|
+
async function materializeHistoricalContinuation(input, candidate, isDraft) {
|
|
322
|
+
const local = await observeRef(input.git, input.repoDir, `refs/heads/${candidate.branch}`);
|
|
323
|
+
if (local.state === 'invalid' || (local.state === 'exact' && local.sha !== candidate.deliverySha))
|
|
324
|
+
return null;
|
|
325
|
+
const remote = input.fetchOk
|
|
326
|
+
? await observeRef(input.git, input.repoDir, `refs/remotes/origin/${candidate.branch}`)
|
|
327
|
+
: { state: 'missing' };
|
|
328
|
+
if (remote.state === 'invalid' || (remote.state === 'exact' && remote.sha !== candidate.deliverySha))
|
|
329
|
+
return null;
|
|
330
|
+
if (local.state !== 'exact' && remote.state !== 'exact')
|
|
331
|
+
return null;
|
|
332
|
+
return new Map(input.ticketIds.map((ticketId) => [ticketId, {
|
|
333
|
+
ticketId,
|
|
334
|
+
deliveryId: candidate.deliveryId,
|
|
335
|
+
branch: candidate.branch,
|
|
336
|
+
baseBranch: candidate.baseBranch,
|
|
337
|
+
...(remote.state === 'exact' ? { baseRef: `origin/${candidate.branch}` } : {}),
|
|
338
|
+
prUrl: candidate.prUrl,
|
|
339
|
+
prNumber: candidate.prNumber,
|
|
340
|
+
isDraft,
|
|
341
|
+
deliverySha: candidate.deliverySha,
|
|
342
|
+
source: 'rail-pr-delivery',
|
|
343
|
+
}]));
|
|
344
|
+
}
|
|
345
|
+
const PR_JSON_FIELDS = 'number,title,body,headRefName,baseRefName,url,isDraft,state,isCrossRepository';
|
|
148
346
|
async function listOpenPrs(exec, repoDir) {
|
|
149
347
|
const r = await exec.run('gh', ['pr', 'list', '--state', 'open', '--limit', '200', '--json', PR_JSON_FIELDS], repoDir);
|
|
150
348
|
if (r.code !== 0)
|
|
@@ -195,35 +393,120 @@ function mergeOpenPrs(prs) {
|
|
|
195
393
|
}
|
|
196
394
|
return out;
|
|
197
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Display-only candidate discovery for the launch UI's "deliver into existing
|
|
398
|
+
* PR" picker. Reuses the same matchers as automatic continuation (PR-number
|
|
399
|
+
* mention in the spec text, Jira key in the PR title/body/head) but WITHOUT
|
|
400
|
+
* the ticket-status gate — suggestions are informational and never
|
|
401
|
+
* auto-selected, so the strict gates guarding automatic adoption don't apply.
|
|
402
|
+
* Matched PRs sort first; the remaining open PRs follow for manual pick.
|
|
403
|
+
*/
|
|
404
|
+
async function listPrCandidatesForTickets(input) {
|
|
405
|
+
const mentioned = new Set();
|
|
406
|
+
for (const ticketId of input.ticketIds) {
|
|
407
|
+
for (const n of mentionedPrNumbers(input.getTicketSpec(ticketId)))
|
|
408
|
+
mentioned.add(n);
|
|
409
|
+
}
|
|
410
|
+
const openPrs = mergeOpenPrs([
|
|
411
|
+
...await listOpenPrs(input.exec, input.repoDir),
|
|
412
|
+
...await mentionedOpenPrs(input.exec, input.repoDir, mentioned),
|
|
413
|
+
]);
|
|
414
|
+
const matched = [];
|
|
415
|
+
const rest = [];
|
|
416
|
+
for (const pr of openPrs) {
|
|
417
|
+
const number = typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url);
|
|
418
|
+
const url = pr.url?.trim();
|
|
419
|
+
const head = pr.headRefName?.trim();
|
|
420
|
+
if (!number || !url || !head)
|
|
421
|
+
continue;
|
|
422
|
+
const candidate = {
|
|
423
|
+
number,
|
|
424
|
+
title: pr.title?.trim() || `PR #${number}`,
|
|
425
|
+
headRefName: head,
|
|
426
|
+
baseRefName: pr.baseRefName?.trim() || '',
|
|
427
|
+
url,
|
|
428
|
+
isDraft: pr.isDraft === true,
|
|
429
|
+
isCrossRepository: pr.isCrossRepository !== false,
|
|
430
|
+
};
|
|
431
|
+
const isMatch = input.ticketIds.some((ticketId) => (prMatchKind(input.db, ticketId, input.getTicketSpec(ticketId), pr) !== null));
|
|
432
|
+
(isMatch ? matched : rest).push(candidate);
|
|
433
|
+
}
|
|
434
|
+
return [...matched, ...rest];
|
|
435
|
+
}
|
|
198
436
|
/**
|
|
199
437
|
* Detects tickets whose next implementation pass should continue an already
|
|
200
438
|
* open PR branch instead of creating fresh work from the integration branch.
|
|
201
|
-
*
|
|
202
|
-
*
|
|
439
|
+
* External discovery is fail-open when no durable Specrails lineage exists.
|
|
440
|
+
* Once an active or newest overlapping historical generation owns the target,
|
|
441
|
+
* however, missing/ambiguous GitHub or ref evidence is authoritative negative
|
|
442
|
+
* evidence and blocks fuzzy fallback to a different PR.
|
|
203
443
|
*/
|
|
204
444
|
async function resolveActivePrContinuationTargets(input) {
|
|
205
445
|
const out = new Map();
|
|
206
|
-
const
|
|
446
|
+
const activeRows = (0, rail_pr_store_1.listActivePrDeliveries)(input.db);
|
|
447
|
+
const internal = internalDeliveryTargets(input.db, activeRows, input.ticketIds);
|
|
207
448
|
const lifecycleByUrl = new Map();
|
|
208
449
|
for (const [ticketId, target] of internal) {
|
|
209
450
|
if (!target.prUrl)
|
|
210
451
|
continue;
|
|
211
452
|
let lifecycle = lifecycleByUrl.get(target.prUrl);
|
|
212
453
|
if (!lifecycle) {
|
|
213
|
-
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl);
|
|
454
|
+
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl, target.deliverySha);
|
|
214
455
|
lifecycleByUrl.set(target.prUrl, lifecycle);
|
|
215
456
|
}
|
|
216
457
|
const observed = await lifecycle;
|
|
217
458
|
// A ledger row is only a hint. GitHub remains lifecycle authority: a stale
|
|
218
459
|
// draft/ready row for a CLOSED/MERGED or different head/base PR must not be
|
|
219
460
|
// superseded and must not lend its branch to another implementation run.
|
|
220
|
-
if (!
|
|
461
|
+
if (!target.deliverySha || !observed.ok ||
|
|
462
|
+
!(0, pr_lifecycle_1.isExactOpenPr)(observed, target.branch, target.baseBranch) ||
|
|
463
|
+
observed.includesExpectedSha !== true ||
|
|
464
|
+
observed.headRefOid?.toLowerCase() !== target.deliverySha.toLowerCase())
|
|
221
465
|
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');
|
|
466
|
+
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
467
|
if (materialized)
|
|
224
468
|
out.set(ticketId, materialized);
|
|
225
469
|
}
|
|
226
|
-
|
|
470
|
+
// Dismiss/Discard closes the continuation generation but deliberately keeps
|
|
471
|
+
// its borrowed PR alive. Recover that exact target only when no active row
|
|
472
|
+
// owns any requested ticket and the latest terminal generation for the whole
|
|
473
|
+
// target is the discarded continuation. Failed historical verification is
|
|
474
|
+
// still authoritative negative evidence: do not fall through to fuzzy PR
|
|
475
|
+
// discovery and accidentally borrow a same-Jira foreign PR.
|
|
476
|
+
const wanted = new Set(input.ticketIds);
|
|
477
|
+
const activeTouchesTarget = activeRows.some((row) => ((0, rail_pr_store_1.toPrDeliverySnapshot)(row).ticketIds.some((ticketId) => wanted.has(ticketId))));
|
|
478
|
+
const historicalOwned = new Set();
|
|
479
|
+
const activeOwned = new Set();
|
|
480
|
+
if (activeTouchesTarget)
|
|
481
|
+
input.ticketIds.forEach((ticketId) => activeOwned.add(ticketId));
|
|
482
|
+
if (!activeTouchesTarget && out.size === 0) {
|
|
483
|
+
const historical = latestHistoricalContinuation(input.db, input.ticketIds);
|
|
484
|
+
if (historical.ownsTarget)
|
|
485
|
+
input.ticketIds.forEach((ticketId) => historicalOwned.add(ticketId));
|
|
486
|
+
if (historical.candidate) {
|
|
487
|
+
const candidate = historical.candidate;
|
|
488
|
+
let lifecycle = lifecycleByUrl.get(candidate.prUrl);
|
|
489
|
+
if (!lifecycle) {
|
|
490
|
+
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, candidate.prUrl, candidate.deliverySha);
|
|
491
|
+
lifecycleByUrl.set(candidate.prUrl, lifecycle);
|
|
492
|
+
}
|
|
493
|
+
const observed = await lifecycle;
|
|
494
|
+
if (observed.ok &&
|
|
495
|
+
(0, pr_lifecycle_1.isExactOpenPr)(observed, candidate.branch, candidate.baseBranch) &&
|
|
496
|
+
observed.headRefOid?.toLowerCase() === candidate.deliverySha &&
|
|
497
|
+
observed.includesExpectedSha === true) {
|
|
498
|
+
const materialized = await materializeHistoricalContinuation(input, candidate, observed.isDraft);
|
|
499
|
+
if (materialized) {
|
|
500
|
+
for (const [ticketId, target] of materialized)
|
|
501
|
+
out.set(ticketId, target);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// A durable Specrails delivery that fails exact verification must never be
|
|
507
|
+
// downgraded into an inferred "external" match for the same ticket/PR. The
|
|
508
|
+
// visible delivery card owns reconciliation of that stale immutable SHA.
|
|
509
|
+
const remaining = input.ticketIds.filter((id) => (!out.has(id) && !internal.has(id) && !historicalOwned.has(id) && !activeOwned.has(id)));
|
|
227
510
|
const candidateIds = remaining.filter((id) => canProbeGithubForContinuation(input.db, id, input.getTicketSpec(id)));
|
|
228
511
|
if (candidateIds.length === 0)
|
|
229
512
|
return out;
|
|
@@ -240,7 +523,7 @@ async function resolveActivePrContinuationTargets(input) {
|
|
|
240
523
|
const spec = input.getTicketSpec(ticketId);
|
|
241
524
|
const matches = openPrs.flatMap((pr) => {
|
|
242
525
|
const kind = prMatchKind(input.db, ticketId, spec, pr);
|
|
243
|
-
return kind
|
|
526
|
+
return kind ? [{ pr, kind }] : [];
|
|
244
527
|
});
|
|
245
528
|
if (matches.length === 0)
|
|
246
529
|
continue;
|
|
@@ -249,7 +532,23 @@ async function resolveActivePrContinuationTargets(input) {
|
|
|
249
532
|
if (best.length !== 1)
|
|
250
533
|
continue;
|
|
251
534
|
const pr = best[0].pr;
|
|
252
|
-
const
|
|
535
|
+
const prUrl = pr.url?.trim();
|
|
536
|
+
const branch = pr.headRefName?.trim();
|
|
537
|
+
const baseBranch = pr.baseRefName?.trim() || input.integrationBranch;
|
|
538
|
+
if (!prUrl || !branch || !baseBranch)
|
|
539
|
+
continue;
|
|
540
|
+
let lifecycle = lifecycleByUrl.get(prUrl);
|
|
541
|
+
if (!lifecycle) {
|
|
542
|
+
lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, prUrl);
|
|
543
|
+
lifecycleByUrl.set(prUrl, lifecycle);
|
|
544
|
+
}
|
|
545
|
+
const observed = await lifecycle;
|
|
546
|
+
// `gh pr list` is discovery evidence only. Freeze the exact live remote
|
|
547
|
+
// head through an authoritative view before borrowing any local branch;
|
|
548
|
+
// otherwise an unrelated local-ahead commit could be carried into the PR.
|
|
549
|
+
if (!observed.ok || !observed.headRefOid || !(0, pr_lifecycle_1.isExactOpenPr)(observed, branch, baseBranch))
|
|
550
|
+
continue;
|
|
551
|
+
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
552
|
if (materialized)
|
|
254
553
|
out.set(ticketId, materialized);
|
|
255
554
|
}
|
|
@@ -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',
|
|
@@ -396,6 +396,15 @@ summary later.
|
|
|
396
396
|
is explicit, covering Jira projects whose Review status has not been mapped to
|
|
397
397
|
Specrails \`on_review\`. You do NOT need to know or pass the branch name. If
|
|
398
398
|
there is no confident open PR match, the normal new-work flow is preserved.
|
|
399
|
+
- **Deliver into an existing PR (explicit target).** When the user names an
|
|
400
|
+
existing PR as the destination ("extend PR #151", "push this into the open
|
|
401
|
+
PR"), pass \`targetPrNumber\` on \`specrails_rails(launch)\` — NEVER launch
|
|
402
|
+
without it in that case, because a plain launch creates a DUPLICATE PR. The
|
|
403
|
+
rail then works on that PR's head branch and settle pushes to it. The PR must
|
|
404
|
+
be open and same-repo; the launch fails closed with \`target_pr_not_found\` /
|
|
405
|
+
\`target_pr_not_open\` / \`target_pr_fork\` / \`target_pr_invalid\` /
|
|
406
|
+
\`target_pr_unfetchable\` — report the reason, do not retry with a fresh
|
|
407
|
+
launch unless the user explicitly chooses a new PR.
|
|
399
408
|
- **PR-aware relaunch classification.** Before classifying or relaunching a
|
|
400
409
|
follow-up for an \`on_review\` spec or active PR, inspect that PR's head
|
|
401
410
|
branch/diff/files, not only \`main\`. Treat OpenSpec artifacts added or
|
|
@@ -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
|