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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanUnreachableRecoveryCommits = scanUnreachableRecoveryCommits;
|
|
4
|
+
exports.discoverRunMarkedCommit = discoverRunMarkedCommit;
|
|
5
|
+
exports.commitCarriesRunMarker = commitCarriesRunMarker;
|
|
6
|
+
exports.recoveryRefForDelivery = recoveryRefForDelivery;
|
|
7
|
+
exports.listRecoveryCommitProtections = listRecoveryCommitProtections;
|
|
8
|
+
exports.inspectRecoveryCommitProtection = inspectRecoveryCommitProtection;
|
|
9
|
+
exports.protectRecoveryCommit = protectRecoveryCommit;
|
|
10
|
+
exports.advanceRecoveryCommitProtection = advanceRecoveryCommitProtection;
|
|
11
|
+
exports.releaseRecoveryCommit = releaseRecoveryCommit;
|
|
12
|
+
const crypto_1 = require("crypto");
|
|
13
|
+
const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
|
|
14
|
+
const MAX_RECOVERY_COMMIT_CANDIDATES = 512;
|
|
15
|
+
/**
|
|
16
|
+
* Read the complete bounded unreachable-commit surface. Unknown output and an
|
|
17
|
+
* excessive candidate universe fail closed: truncating either could turn an
|
|
18
|
+
* ambiguous recovery into a false unique result.
|
|
19
|
+
*/
|
|
20
|
+
async function scanUnreachableRecoveryCommits(git, repoDir) {
|
|
21
|
+
try {
|
|
22
|
+
const result = await git.run(['fsck', '--unreachable', '--no-reflogs', '--no-progress'], repoDir);
|
|
23
|
+
if (result.code !== 0) {
|
|
24
|
+
return { ok: false, detail: 'Git could not enumerate unreachable recovery objects' };
|
|
25
|
+
}
|
|
26
|
+
const commits = new Set();
|
|
27
|
+
for (const rawLine of result.stdout.split(/\r?\n/)) {
|
|
28
|
+
const line = rawLine.trim();
|
|
29
|
+
if (!line)
|
|
30
|
+
continue;
|
|
31
|
+
const commit = /^unreachable commit ([0-9a-f]{40,64})$/i.exec(line);
|
|
32
|
+
if (commit) {
|
|
33
|
+
commits.add(commit[1].toLowerCase());
|
|
34
|
+
if (commits.size > MAX_RECOVERY_COMMIT_CANDIDATES) {
|
|
35
|
+
return { ok: false, detail: 'Git exposed too many unreachable commits for safe recovery' };
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (/^(?:unreachable|dangling) (?:blob|tree|tag) [0-9a-f]{40,64}$/i.test(line))
|
|
40
|
+
continue;
|
|
41
|
+
return { ok: false, detail: 'Git returned malformed unreachable-object evidence' };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, commits: [...commits] };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { ok: false, detail: 'Git could not enumerate unreachable recovery objects' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Combine refs, reflogs and unreachable commits, then inspect every subject.
|
|
51
|
+
* Any enumeration/inspection failure invalidates the entire uniqueness proof.
|
|
52
|
+
*/
|
|
53
|
+
async function discoverRunMarkedCommit(git, repoDir, runId, suppliedScan) {
|
|
54
|
+
if (!runId || runId.length > 256 || /[\0\r\n]/.test(runId)) {
|
|
55
|
+
return { kind: 'scan_failed', detail: 'The recorded run identifier is not safe to inspect' };
|
|
56
|
+
}
|
|
57
|
+
const unreachable = suppliedScan ?? await scanUnreachableRecoveryCommits(git, repoDir);
|
|
58
|
+
if (!unreachable.ok)
|
|
59
|
+
return { kind: 'scan_failed', detail: unreachable.detail };
|
|
60
|
+
const candidates = new Set(unreachable.commits.map((sha) => sha.toLowerCase()));
|
|
61
|
+
let refs;
|
|
62
|
+
try {
|
|
63
|
+
refs = await git.run(['log', '--all', '--reflog', '--fixed-strings', `--grep=(run ${runId})`, '--format=%H'], repoDir);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { kind: 'scan_failed', detail: 'Git could not enumerate recovery refs and reflogs' };
|
|
67
|
+
}
|
|
68
|
+
if (refs.code !== 0) {
|
|
69
|
+
return { kind: 'scan_failed', detail: 'Git could not enumerate recovery refs and reflogs' };
|
|
70
|
+
}
|
|
71
|
+
for (const rawLine of refs.stdout.split(/\r?\n/)) {
|
|
72
|
+
const sha = rawLine.trim();
|
|
73
|
+
if (!sha)
|
|
74
|
+
continue;
|
|
75
|
+
if (!COMMIT_SHA_RE.test(sha)) {
|
|
76
|
+
return { kind: 'scan_failed', detail: 'Git returned malformed recovery commit evidence' };
|
|
77
|
+
}
|
|
78
|
+
candidates.add(sha.toLowerCase());
|
|
79
|
+
if (candidates.size > MAX_RECOVERY_COMMIT_CANDIDATES) {
|
|
80
|
+
return { kind: 'scan_failed', detail: 'Git exposed too many commits for safe recovery' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const marker = `(run ${runId})`;
|
|
84
|
+
const marked = [];
|
|
85
|
+
for (const sha of candidates) {
|
|
86
|
+
let subject;
|
|
87
|
+
try {
|
|
88
|
+
subject = await git.run(['show', '-s', '--format=%s', sha], repoDir);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { kind: 'scan_failed', detail: 'Git could not inspect every recovery commit subject' };
|
|
92
|
+
}
|
|
93
|
+
if (subject.code !== 0) {
|
|
94
|
+
return { kind: 'scan_failed', detail: 'Git could not inspect every recovery commit subject' };
|
|
95
|
+
}
|
|
96
|
+
if (subject.stdout.trim().includes(marker))
|
|
97
|
+
marked.push(sha);
|
|
98
|
+
}
|
|
99
|
+
if (marked.length === 0)
|
|
100
|
+
return { kind: 'none' };
|
|
101
|
+
if (marked.length > 1)
|
|
102
|
+
return { kind: 'ambiguous', count: marked.length };
|
|
103
|
+
return { kind: 'unique', sha: marked[0] };
|
|
104
|
+
}
|
|
105
|
+
async function commitCarriesRunMarker(git, repoDir, sha, runId) {
|
|
106
|
+
if (!COMMIT_SHA_RE.test(sha))
|
|
107
|
+
return false;
|
|
108
|
+
try {
|
|
109
|
+
const result = await git.run(['show', '-s', '--format=%s', sha], repoDir);
|
|
110
|
+
return result.code === 0 && result.stdout.trim().includes(`(run ${runId})`);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function recoveryRefForDelivery(deliveryId) {
|
|
117
|
+
const digest = (0, crypto_1.createHash)('sha256').update(deliveryId).digest('hex');
|
|
118
|
+
return `refs/specrails/recovery/${digest}`;
|
|
119
|
+
}
|
|
120
|
+
async function listRecoveryCommitProtections(git, repoDir) {
|
|
121
|
+
try {
|
|
122
|
+
const result = await git.run(['for-each-ref', '--format=%(refname)%09%(objectname)', 'refs/specrails/recovery/'], repoDir);
|
|
123
|
+
if (result.code !== 0) {
|
|
124
|
+
return { ok: false, detail: 'Git could not enumerate delivery recovery refs' };
|
|
125
|
+
}
|
|
126
|
+
const protections = new Map();
|
|
127
|
+
for (const rawLine of result.stdout.split(/\r?\n/)) {
|
|
128
|
+
const line = rawLine.trim();
|
|
129
|
+
if (!line)
|
|
130
|
+
continue;
|
|
131
|
+
const match = /^(refs\/specrails\/recovery\/[0-9a-f]{64})\t([0-9a-f]{40,64})$/i.exec(line);
|
|
132
|
+
if (!match || protections.has(match[1])) {
|
|
133
|
+
return { ok: false, detail: 'Git returned malformed delivery recovery refs' };
|
|
134
|
+
}
|
|
135
|
+
protections.set(match[1], match[2].toLowerCase());
|
|
136
|
+
if (protections.size > MAX_RECOVERY_COMMIT_CANDIDATES) {
|
|
137
|
+
return { ok: false, detail: 'Git exposed too many delivery recovery refs for safe cleanup' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, protections };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { ok: false, detail: 'Git could not enumerate delivery recovery refs' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function inspectRecoveryCommitProtection(git, repoDir, deliveryId) {
|
|
147
|
+
const ref = recoveryRefForDelivery(deliveryId);
|
|
148
|
+
try {
|
|
149
|
+
const exists = await git.run(['show-ref', '--verify', '--quiet', ref], repoDir);
|
|
150
|
+
if (exists.code === 1)
|
|
151
|
+
return { kind: 'absent', ref };
|
|
152
|
+
if (exists.code !== 0)
|
|
153
|
+
return { kind: 'unreadable', ref };
|
|
154
|
+
const result = await git.run(['rev-parse', '--verify', ref], repoDir);
|
|
155
|
+
if (result.code !== 0)
|
|
156
|
+
return { kind: 'unreadable', ref };
|
|
157
|
+
const sha = result.stdout.trim().toLowerCase();
|
|
158
|
+
return COMMIT_SHA_RE.test(sha)
|
|
159
|
+
? { kind: 'present', ref, sha }
|
|
160
|
+
: { kind: 'unreadable', ref };
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return { kind: 'unreadable', ref };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Pin a proven orphan without moving any user-visible branch or checkout. */
|
|
167
|
+
async function protectRecoveryCommit(git, repoDir, deliveryId, sha) {
|
|
168
|
+
if (!COMMIT_SHA_RE.test(sha))
|
|
169
|
+
return { ok: false, detail: 'The recovery commit SHA is invalid' };
|
|
170
|
+
const ref = recoveryRefForDelivery(deliveryId);
|
|
171
|
+
const zero = '0'.repeat(sha.length);
|
|
172
|
+
try {
|
|
173
|
+
const created = await git.run(['update-ref', ref, sha, zero], repoDir);
|
|
174
|
+
if (created.code !== 0) {
|
|
175
|
+
const existing = await git.run(['rev-parse', '--verify', ref], repoDir);
|
|
176
|
+
if (existing.code !== 0 || existing.stdout.trim().toLowerCase() !== sha.toLowerCase()) {
|
|
177
|
+
return { ok: false, detail: 'A different commit already owns this delivery recovery ref' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const verified = await git.run(['rev-parse', '--verify', ref], repoDir);
|
|
181
|
+
if (verified.code !== 0 || verified.stdout.trim().toLowerCase() !== sha.toLowerCase()) {
|
|
182
|
+
return { ok: false, detail: 'Git could not verify the delivery recovery ref' };
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, ref };
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return { ok: false, detail: 'Git could not protect the recovered commit from cleanup' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Move an already-owned protection ref to a newly committed descendant using
|
|
191
|
+
* an exact compare-and-set. The old orphan remains pinned unless the caller
|
|
192
|
+
* proves it still owns the ref at the instant of the update. */
|
|
193
|
+
async function advanceRecoveryCommitProtection(git, repoDir, deliveryId, previousSha, nextSha) {
|
|
194
|
+
if (!COMMIT_SHA_RE.test(previousSha) || !COMMIT_SHA_RE.test(nextSha)) {
|
|
195
|
+
return { ok: false, detail: 'The recovery commit SHA is invalid' };
|
|
196
|
+
}
|
|
197
|
+
if (previousSha.toLowerCase() === nextSha.toLowerCase()) {
|
|
198
|
+
return protectRecoveryCommit(git, repoDir, deliveryId, nextSha);
|
|
199
|
+
}
|
|
200
|
+
const ref = recoveryRefForDelivery(deliveryId);
|
|
201
|
+
try {
|
|
202
|
+
const moved = await git.run(['update-ref', ref, nextSha, previousSha], repoDir);
|
|
203
|
+
if (moved.code !== 0) {
|
|
204
|
+
return { ok: false, detail: 'The delivery recovery ref changed before the new commit could be protected' };
|
|
205
|
+
}
|
|
206
|
+
const verified = await git.run(['rev-parse', '--verify', ref], repoDir);
|
|
207
|
+
if (verified.code !== 0 || verified.stdout.trim().toLowerCase() !== nextSha.toLowerCase()) {
|
|
208
|
+
return { ok: false, detail: 'Git could not verify the advanced delivery recovery ref' };
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, ref };
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return { ok: false, detail: 'Git could not advance the delivery recovery ref safely' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Delete only the exact protected object; a substituted ref is preserved. */
|
|
217
|
+
async function releaseRecoveryCommit(git, repoDir, deliveryId, expectedSha) {
|
|
218
|
+
// A missing/invalid expected object is not authority to delete a durable
|
|
219
|
+
// protection ref. Callers may be settling legacy rows that never created
|
|
220
|
+
// one, while a concurrently repaired row could already own this delivery's
|
|
221
|
+
// ref. Preserve unknown state rather than deleting by name alone.
|
|
222
|
+
if (!expectedSha || !COMMIT_SHA_RE.test(expectedSha))
|
|
223
|
+
return false;
|
|
224
|
+
const ref = recoveryRefForDelivery(deliveryId);
|
|
225
|
+
try {
|
|
226
|
+
const removed = await git.run(['update-ref', '-d', ref, expectedSha], repoDir);
|
|
227
|
+
if (removed.code === 0)
|
|
228
|
+
return true;
|
|
229
|
+
const existing = await git.run(['rev-parse', '--verify', ref], repoDir);
|
|
230
|
+
return existing.code !== 0;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -8,10 +8,15 @@ exports.failPrDeliveryAndRestoreSuperseded = failPrDeliveryAndRestoreSuperseded;
|
|
|
8
8
|
exports.getPrDelivery = getPrDelivery;
|
|
9
9
|
exports.getActivePrDeliveryByRail = getActivePrDeliveryByRail;
|
|
10
10
|
exports.listActivePrDeliveries = listActivePrDeliveries;
|
|
11
|
+
exports.listTerminalPrDeliveries = listTerminalPrDeliveries;
|
|
12
|
+
exports.getLatestTerminalPrDeliveryTouchingTicketSet = getLatestTerminalPrDeliveryTouchingTicketSet;
|
|
11
13
|
exports.listOriginLinkedPrDeliveries = listOriginLinkedPrDeliveries;
|
|
12
14
|
exports.reconcileFailedBuildingPrDeliveries = reconcileFailedBuildingPrDeliveries;
|
|
13
15
|
exports.boundPrDiagnostic = boundPrDiagnostic;
|
|
14
16
|
exports.boundCleanupWarnings = boundCleanupWarnings;
|
|
17
|
+
exports.normalizeSafetyArchives = normalizeSafetyArchives;
|
|
18
|
+
exports.mergeSafetyArchives = mergeSafetyArchives;
|
|
19
|
+
exports.appendPrDeliverySafetyArchive = appendPrDeliverySafetyArchive;
|
|
15
20
|
exports.transitionDecision = transitionDecision;
|
|
16
21
|
exports.transitionClaimedDecision = transitionClaimedDecision;
|
|
17
22
|
exports.claimPrDeliveryOperation = claimPrDeliveryOperation;
|
|
@@ -65,6 +70,16 @@ class PrDeliveryGenerationConflict extends Error {
|
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
exports.PrDeliveryGenerationConflict = PrDeliveryGenerationConflict;
|
|
73
|
+
/** SQLite's `datetime('now')` has one-second precision, so a claim, transition,
|
|
74
|
+
* and release in the same second used to produce conflicting snapshots with an
|
|
75
|
+
* identical `updatedAt`. Clients must fail closed on such ties. Keep the wire
|
|
76
|
+
* timestamp strictly increasing per row while retaining a parseable UTC ISO
|
|
77
|
+
* value for legacy rows and every mutation path. */
|
|
78
|
+
const NEXT_PR_UPDATED_AT_SQL = `CASE
|
|
79
|
+
WHEN julianday('now') > julianday(updated_at)
|
|
80
|
+
THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
81
|
+
ELSE strftime('%Y-%m-%dT%H:%M:%fZ', updated_at, '+0.001 seconds')
|
|
82
|
+
END`;
|
|
68
83
|
/** Recheck admission and, for a continuation, replace the prior generation in
|
|
69
84
|
* one SQLite transaction. The partial unique index is therefore an invariant,
|
|
70
85
|
* not merely a router convention. Pass null for a genuinely fresh launch. */
|
|
@@ -87,7 +102,8 @@ function createPrDeliveryGeneration(db, input, expectedActive) {
|
|
|
87
102
|
};
|
|
88
103
|
const moved = db.prepare(`
|
|
89
104
|
UPDATE rail_pr_deliveries
|
|
90
|
-
SET decision = 'superseded',
|
|
105
|
+
SET decision = 'superseded', restored_from_delivery_id = NULL,
|
|
106
|
+
updated_at = ${NEXT_PR_UPDATED_AT_SQL}
|
|
91
107
|
WHERE id = ? AND decision = ? AND operation_token IS NULL
|
|
92
108
|
`).run(current.id, current.decision);
|
|
93
109
|
if (moved.changes !== 1)
|
|
@@ -112,16 +128,17 @@ function failPrDeliveryAndRestoreSuperseded(db, failedId, previous) {
|
|
|
112
128
|
const failed = db.prepare(`
|
|
113
129
|
UPDATE rail_pr_deliveries
|
|
114
130
|
SET decision = 'discarded', status_code = 'delivery_failed',
|
|
115
|
-
delivery_outcome = 'blocked', updated_at =
|
|
131
|
+
delivery_outcome = 'blocked', updated_at = ${NEXT_PR_UPDATED_AT_SQL}
|
|
116
132
|
WHERE id = ? AND decision = 'building'
|
|
117
133
|
`).run(failedId);
|
|
118
134
|
if (failed.changes !== 1)
|
|
119
135
|
return false;
|
|
120
136
|
const restored = db.prepare(`
|
|
121
137
|
UPDATE rail_pr_deliveries
|
|
122
|
-
SET decision = ?, status_code = ?,
|
|
138
|
+
SET decision = ?, status_code = ?, restored_from_delivery_id = ?,
|
|
139
|
+
updated_at = ${NEXT_PR_UPDATED_AT_SQL}
|
|
123
140
|
WHERE id = ? AND decision = 'superseded'
|
|
124
|
-
`).run(previous.decision, previous.statusCode, previous.id);
|
|
141
|
+
`).run(previous.decision, previous.statusCode, failedId, previous.id);
|
|
125
142
|
if (restored.changes !== 1)
|
|
126
143
|
throw new Error(`failed to restore superseded delivery ${previous.id}`);
|
|
127
144
|
return true;
|
|
@@ -150,6 +167,39 @@ function listActivePrDeliveries(db) {
|
|
|
150
167
|
ORDER BY rail_index, created_at DESC, rowid DESC`)
|
|
151
168
|
.all();
|
|
152
169
|
}
|
|
170
|
+
/** Terminal delivery history, newest first. Continuation discovery uses this
|
|
171
|
+
* only as a conservative ownership hint: callers must still require an exact
|
|
172
|
+
* ticket target and revalidate the recorded PR/ref identity before reuse. */
|
|
173
|
+
function listTerminalPrDeliveries(db) {
|
|
174
|
+
return db
|
|
175
|
+
.prepare(`SELECT * FROM rail_pr_deliveries
|
|
176
|
+
WHERE decision IN ('completed','merged','discarded','superseded')
|
|
177
|
+
ORDER BY created_at DESC, rowid DESC`)
|
|
178
|
+
.all();
|
|
179
|
+
}
|
|
180
|
+
function canonicalTicketSet(values) {
|
|
181
|
+
if (values.length === 0)
|
|
182
|
+
return null;
|
|
183
|
+
const tickets = new Set();
|
|
184
|
+
for (const value of values) {
|
|
185
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
|
|
186
|
+
tickets.add(value);
|
|
187
|
+
}
|
|
188
|
+
return tickets.size > 0 ? [...tickets].sort((a, b) => a - b).join(',') : null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Newest terminal generation touching any requested ticket. The returned row
|
|
192
|
+
* is intentionally not otherwise validated: a newer subset, superset, or
|
|
193
|
+
* malformed row still owns the overlapping ticket lineage and must shadow
|
|
194
|
+
* older exact-set history rather than letting a caller resurrect a stale PR.
|
|
195
|
+
*/
|
|
196
|
+
function getLatestTerminalPrDeliveryTouchingTicketSet(db, ticketIds) {
|
|
197
|
+
const target = canonicalTicketSet(ticketIds);
|
|
198
|
+
if (!target || target.split(',').length !== ticketIds.length)
|
|
199
|
+
return undefined;
|
|
200
|
+
const wanted = new Set(target.split(',').map(Number));
|
|
201
|
+
return listTerminalPrDeliveries(db).find((row) => (parseJsonArray(row.ticket_ids).some((value) => (typeof value === 'number' && Number.isSafeInteger(value) && wanted.has(value)))));
|
|
202
|
+
}
|
|
153
203
|
/** Startup projection source for persisted Agent cards. Terminal rows matter:
|
|
154
204
|
* a process may die after their SQLite transition but before the conversation
|
|
155
205
|
* envelope update, leaving an obsolete actionable card pinned forever. */
|
|
@@ -294,6 +344,7 @@ const PATCH_COLUMNS = {
|
|
|
294
344
|
operationToken: 'operation_token',
|
|
295
345
|
operationStartedAtMs: 'operation_started_at_ms',
|
|
296
346
|
cleanupWarnings: 'cleanup_warnings',
|
|
347
|
+
safetyArchives: 'safety_archives',
|
|
297
348
|
};
|
|
298
349
|
function patchValue(key, patch) {
|
|
299
350
|
const raw = patch[key];
|
|
@@ -301,6 +352,8 @@ function patchValue(key, patch) {
|
|
|
301
352
|
return JSON.stringify(raw);
|
|
302
353
|
if (key === 'cleanupWarnings')
|
|
303
354
|
return JSON.stringify(boundCleanupWarnings(raw));
|
|
355
|
+
if (key === 'safetyArchives')
|
|
356
|
+
return JSON.stringify(normalizeSafetyArchives(raw));
|
|
304
357
|
if (key === 'statusDetail')
|
|
305
358
|
return raw == null ? null : boundPrDiagnostic(String(raw));
|
|
306
359
|
if (key === 'isContinuation')
|
|
@@ -317,6 +370,45 @@ function boundPrDiagnostic(value, maxLength = 512) {
|
|
|
317
370
|
function boundCleanupWarnings(values) {
|
|
318
371
|
return [...new Set(values.map((value) => boundPrDiagnostic(value)).filter(Boolean))].slice(0, 8);
|
|
319
372
|
}
|
|
373
|
+
/** Safety archive paths must remain exact filesystem locations. They are
|
|
374
|
+
* internally generated pointers to bytes that still occupy disk, so dropping
|
|
375
|
+
* an older pointer is never a valid payload-size optimization. Deduplicate
|
|
376
|
+
* exact non-empty OS-representable paths without trimming them. */
|
|
377
|
+
function normalizeSafetyArchives(values) {
|
|
378
|
+
const newestUnique = [];
|
|
379
|
+
for (const value of values) {
|
|
380
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 8192 || value.includes('\0'))
|
|
381
|
+
continue;
|
|
382
|
+
const previous = newestUnique.indexOf(value);
|
|
383
|
+
if (previous >= 0)
|
|
384
|
+
newestUnique.splice(previous, 1);
|
|
385
|
+
newestUnique.push(value);
|
|
386
|
+
}
|
|
387
|
+
return newestUnique;
|
|
388
|
+
}
|
|
389
|
+
function mergeSafetyArchives(existing, additions) {
|
|
390
|
+
return normalizeSafetyArchives([...existing, ...additions]);
|
|
391
|
+
}
|
|
392
|
+
/** Add one quarantine path independently of lifecycle state. Safety archives
|
|
393
|
+
* are monotonic recovery metadata: a concurrent decision transition must not
|
|
394
|
+
* erase a path created just before a crash. */
|
|
395
|
+
function appendPrDeliverySafetyArchive(db, id, archive) {
|
|
396
|
+
return db.transaction(() => {
|
|
397
|
+
const row = db.prepare(`SELECT safety_archives FROM rail_pr_deliveries WHERE id = ?`)
|
|
398
|
+
.get(id);
|
|
399
|
+
if (!row)
|
|
400
|
+
return null;
|
|
401
|
+
const existing = normalizeSafetyArchives(parseJsonArray(row.safety_archives ?? '[]')
|
|
402
|
+
.filter((value) => typeof value === 'string'));
|
|
403
|
+
const merged = mergeSafetyArchives(existing, [archive]);
|
|
404
|
+
db.prepare(`
|
|
405
|
+
UPDATE rail_pr_deliveries
|
|
406
|
+
SET safety_archives = ?, updated_at = ${NEXT_PR_UPDATED_AT_SQL}
|
|
407
|
+
WHERE id = ?
|
|
408
|
+
`).run(JSON.stringify(merged), id);
|
|
409
|
+
return merged;
|
|
410
|
+
})();
|
|
411
|
+
}
|
|
320
412
|
/**
|
|
321
413
|
* COMPARE-AND-SET decision transition: one atomic
|
|
322
414
|
* `UPDATE … WHERE id = ? AND decision = ?` (no read-modify-write), so two
|
|
@@ -331,7 +423,7 @@ function transitionDecision(db, id, expected, next, patch = {}) {
|
|
|
331
423
|
const values = keys.map((k) => patchValue(k, patch));
|
|
332
424
|
return (db
|
|
333
425
|
.prepare(`UPDATE rail_pr_deliveries
|
|
334
|
-
SET decision = ?, updated_at =
|
|
426
|
+
SET decision = ?, updated_at = ${NEXT_PR_UPDATED_AT_SQL}${patchClause}
|
|
335
427
|
WHERE id = ? AND decision = ?`)
|
|
336
428
|
.run(next, ...values, id, expected).changes > 0);
|
|
337
429
|
}
|
|
@@ -347,7 +439,7 @@ function transitionClaimedDecision(db, id, expected, next, operationToken, patch
|
|
|
347
439
|
const values = keys.map((key) => patchValue(key, patch));
|
|
348
440
|
return db.prepare(`
|
|
349
441
|
UPDATE rail_pr_deliveries
|
|
350
|
-
SET decision = ?, updated_at =
|
|
442
|
+
SET decision = ?, updated_at = ${NEXT_PR_UPDATED_AT_SQL}${patchClause}
|
|
351
443
|
WHERE id = ? AND decision = ? AND operation_token = ?
|
|
352
444
|
`).run(next, ...values, id, expected, operationToken).changes > 0;
|
|
353
445
|
}
|
|
@@ -362,7 +454,7 @@ function claimPrDeliveryOperation(db, id, expected, operation, token, nowMs = Da
|
|
|
362
454
|
return db.prepare(`
|
|
363
455
|
UPDATE rail_pr_deliveries
|
|
364
456
|
SET operation = ?, operation_token = ?, operation_started_at_ms = ?,
|
|
365
|
-
updated_at =
|
|
457
|
+
updated_at = ${NEXT_PR_UPDATED_AT_SQL}
|
|
366
458
|
WHERE id = ? AND decision = ?
|
|
367
459
|
AND (operation_token IS NULL OR operation_started_at_ms IS NULL OR operation_started_at_ms < ?)
|
|
368
460
|
`).run(operation, token, nowMs, id, expected, staleBefore).changes > 0;
|
|
@@ -373,7 +465,7 @@ function releasePrDeliveryOperation(db, id, token) {
|
|
|
373
465
|
return db.prepare(`
|
|
374
466
|
UPDATE rail_pr_deliveries
|
|
375
467
|
SET operation = NULL, operation_token = NULL, operation_started_at_ms = NULL,
|
|
376
|
-
updated_at =
|
|
468
|
+
updated_at = ${NEXT_PR_UPDATED_AT_SQL}
|
|
377
469
|
WHERE id = ? AND operation_token = ?
|
|
378
470
|
`).run(id, token).changes > 0;
|
|
379
471
|
}
|
|
@@ -387,16 +479,24 @@ function clearOrphanedPrDeliveryOperations(db) {
|
|
|
387
479
|
SET operation = NULL, operation_token = NULL, operation_started_at_ms = NULL,
|
|
388
480
|
status_code = CASE
|
|
389
481
|
WHEN decision NOT IN ('completed','merged','discarded','superseded')
|
|
482
|
+
AND NOT (
|
|
483
|
+
operation = 'recover-and-retry'
|
|
484
|
+
AND status_code IN ('settlement_interrupted','recovery_unavailable')
|
|
485
|
+
)
|
|
390
486
|
THEN 'operation_interrupted'
|
|
391
487
|
ELSE status_code
|
|
392
488
|
END,
|
|
393
489
|
status_detail = CASE
|
|
394
490
|
WHEN decision NOT IN ('completed','merged','discarded','superseded')
|
|
491
|
+
AND NOT (
|
|
492
|
+
operation = 'recover-and-retry'
|
|
493
|
+
AND status_code IN ('settlement_interrupted','recovery_unavailable')
|
|
494
|
+
)
|
|
395
495
|
AND (status_detail IS NULL OR status_detail = '')
|
|
396
496
|
THEN 'A previous delivery action was interrupted by restart. Its durable evidence was preserved; review the current state and retry.'
|
|
397
497
|
ELSE status_detail
|
|
398
498
|
END,
|
|
399
|
-
updated_at =
|
|
499
|
+
updated_at = ${NEXT_PR_UPDATED_AT_SQL}
|
|
400
500
|
WHERE operation IS NOT NULL OR operation_token IS NOT NULL OR operation_started_at_ms IS NOT NULL
|
|
401
501
|
`).run().changes;
|
|
402
502
|
}
|
|
@@ -430,8 +530,11 @@ function toPrDeliverySnapshot(row) {
|
|
|
430
530
|
deliverySha: row.delivery_sha ?? null,
|
|
431
531
|
isContinuation: row.is_continuation === 1,
|
|
432
532
|
supersedesDeliveryId: row.supersedes_delivery_id ?? null,
|
|
533
|
+
restoredFromDeliveryId: row.restored_from_delivery_id ?? null,
|
|
433
534
|
operation: row.operation ?? null,
|
|
434
535
|
cleanupWarnings: parseJsonArray(row.cleanup_warnings ?? '[]'),
|
|
536
|
+
safetyArchives: normalizeSafetyArchives(parseJsonArray(row.safety_archives ?? '[]')
|
|
537
|
+
.filter((value) => typeof value === 'string')),
|
|
435
538
|
branches: units,
|
|
436
539
|
units,
|
|
437
540
|
loopName: row.loop_name,
|
|
@@ -470,11 +573,15 @@ function toRailPrStateMessage(projectId, snap) {
|
|
|
470
573
|
deliverySha: snap.deliverySha,
|
|
471
574
|
isContinuation: snap.isContinuation,
|
|
472
575
|
supersedesDeliveryId: snap.supersedesDeliveryId,
|
|
576
|
+
restoredFromDeliveryId: snap.restoredFromDeliveryId,
|
|
473
577
|
operation: snap.operation,
|
|
474
578
|
cleanupWarnings: snap.cleanupWarnings,
|
|
579
|
+
safetyArchives: snap.safetyArchives,
|
|
475
580
|
units: snap.units,
|
|
476
581
|
runIds: snap.runIds,
|
|
477
582
|
originConversationId: snap.originConversationId,
|
|
583
|
+
createdAt: snap.createdAt,
|
|
584
|
+
updatedAt: snap.updatedAt,
|
|
478
585
|
};
|
|
479
586
|
}
|
|
480
587
|
/**
|
|
@@ -499,13 +606,17 @@ function toPrDecisionCardEnvelope(projectId, snap) {
|
|
|
499
606
|
deliverySha: snap.deliverySha,
|
|
500
607
|
isContinuation: snap.isContinuation,
|
|
501
608
|
supersedesDeliveryId: snap.supersedesDeliveryId,
|
|
609
|
+
restoredFromDeliveryId: snap.restoredFromDeliveryId,
|
|
502
610
|
operation: snap.operation,
|
|
503
611
|
cleanupWarnings: snap.cleanupWarnings,
|
|
612
|
+
safetyArchives: snap.safetyArchives,
|
|
504
613
|
units: snap.units,
|
|
505
614
|
prUrl: snap.prUrl,
|
|
506
615
|
prNumber: snap.prNumber,
|
|
507
616
|
prState: snap.prState,
|
|
508
617
|
branch: snap.branch,
|
|
509
618
|
runIds: snap.runIds,
|
|
619
|
+
createdAt: snap.createdAt,
|
|
620
|
+
updatedAt: snap.updatedAt,
|
|
510
621
|
};
|
|
511
622
|
}
|