specrails-desktop 2.34.1 → 2.35.0
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-LCBJ5WiD.js +1 -0
- package/client/dist/assets/AgentBrowserCapture-B-xQpyZr.js +1 -0
- package/client/dist/assets/AgentModeAnalyticsPane-BI1Vcf3T.js +2 -0
- package/client/dist/assets/AgentModeCodePane-BTRwCl9h.js +2 -0
- package/client/dist/assets/AgentModeJobsPane-VzeBJLqP.js +1 -0
- package/client/dist/assets/AgentsPage-CJj1K24O.js +87 -0
- package/client/dist/assets/AnalyticsPage-CQpV5_fX.js +1 -0
- package/client/dist/assets/CodePage-9-tFh0jB.js +2 -0
- package/client/dist/assets/DesktopAnalyticsPage-BcZ0_O_C.js +1 -0
- package/client/dist/assets/DocsDialog-BPAzDICW.js +11 -0
- package/client/dist/assets/DocsPage-Dqo4Hn6G.js +11 -0
- package/client/dist/assets/IntegrationsPage-Bhy58lnQ.js +1 -0
- package/client/dist/assets/InteractiveJobComposer-D36fZWai.js +19 -0
- package/client/dist/assets/JobDetailModal-BxsktbkQ.js +1 -0
- package/client/dist/assets/JobDetailPage-WWyCy6Sp.js +1 -0
- package/client/dist/assets/JobsPage-CEbM7uxU.js +1 -0
- package/client/dist/assets/{LoopBuilderPage-B1GJJ0QZ.js → LoopBuilderPage-CYsSN8io.js} +2 -2
- package/client/dist/assets/LoopPreviewModal-BJhgX5wY.js +1 -0
- package/client/dist/assets/LoopsPage-DB4OqMot.js +1 -0
- package/client/dist/assets/MinimizedChatsContext-CcjBHMHd.js +1 -0
- package/client/dist/assets/PluginsPage-CR8K4nM_.js +2 -0
- package/client/dist/assets/{ProjectSettingsDialog-D6Vxjpv5.js → ProjectSettingsDialog-C45-qhF4.js} +1 -1
- package/client/dist/assets/ReviewPacketPage-Cz_QQeC3.js +1 -0
- package/client/dist/assets/TemplatePreviewModal-wFBd2vQP.js +1 -0
- package/client/dist/assets/TicketDetailModal-BvZoyDAL.js +14 -0
- package/client/dist/assets/{Trans-CTqw6rr9.js → Trans-DXZ-BLXb.js} +1 -1
- package/client/dist/assets/agent-BQgKv_2F.js +1 -0
- package/client/dist/assets/agent-C1NRDJqL.js +1 -0
- package/client/dist/assets/agent-CLNleiy6.js +1 -0
- package/client/dist/assets/agent-CmceArkQ.js +1 -0
- package/client/dist/assets/agent-D3UxQ3SR.js +1 -0
- package/client/dist/assets/agent-DB1JeE64.js +1 -0
- package/client/dist/assets/agent-DXkHcQ-D.js +1 -0
- package/client/dist/assets/agent-DjJJJ2M8.js +1 -0
- package/client/dist/assets/ban-E4EaZe3a.js +1 -0
- package/client/dist/assets/bot-DzuuuC5R.js +1 -0
- package/client/dist/assets/{browser-DxP-alBK.js → browser-BgqHDMXP.js} +1 -1
- package/client/dist/assets/{browser-CyR1Kp3m.js → browser-CsKJtrpv.js} +1 -1
- package/client/dist/assets/browser-Cu58EpZU.js +1 -0
- package/client/dist/assets/{browser-rRCwi7Y7.js → browser-CvXOJm-j.js} +1 -1
- package/client/dist/assets/{browser-CvgDySd9.js → browser-DOrFh1c7.js} +1 -1
- package/client/dist/assets/{browser-DwZljcRh.js → browser-DQv_Jo7x.js} +1 -1
- package/client/dist/assets/{browser-C25DkQiO.js → browser-LPgxvJu2.js} +1 -1
- package/client/dist/assets/{browser-CiJ7-L32.js → browser-eL-3MIEI.js} +1 -1
- package/client/dist/assets/button-CJqFnUOL.js +1 -0
- package/client/dist/assets/{chunk-KS7C4IRE-Cx5sqopX.js → chunk-KS7C4IRE-DEKu087k.js} +1 -1
- package/client/dist/assets/{commands-DyUZQ1FI.js → commands-6tYQih5m.js} +1 -1
- package/client/dist/assets/{commands-B9RZO4qS.js → commands-BK2QinXN.js} +1 -1
- package/client/dist/assets/{commands-Bbxu8kLU.js → commands-C2Nca76h.js} +1 -1
- package/client/dist/assets/{commands-CDU3qv68.js → commands-CKdLihNX.js} +1 -1
- package/client/dist/assets/{commands-DTQBF_q5.js → commands-CYIBTAlc.js} +1 -1
- package/client/dist/assets/{commands-hOtKuOXc.js → commands-CjUtYJIM.js} +1 -1
- package/client/dist/assets/{commands-BEwbAjtD.js → commands-Cq_u_8y9.js} +1 -1
- package/client/dist/assets/{commands-CzezgtQj.js → commands-D_ZHYQug.js} +1 -1
- package/client/dist/assets/core-B3mKBIh5.js +1 -0
- package/client/dist/assets/dashboard-B9GhC5ku.js +1 -0
- package/client/dist/assets/dashboard-C8WOEiPu.js +1 -0
- package/client/dist/assets/dashboard-CBnv6zOI.js +1 -0
- package/client/dist/assets/dashboard-CaP0fU87.js +1 -0
- package/client/dist/assets/dashboard-D4ZZ4x84.js +1 -0
- package/client/dist/assets/dashboard-DmuTQBX8.js +1 -0
- package/client/dist/assets/dashboard-DynLYj3d.js +1 -0
- package/client/dist/assets/dashboard-zj4gGFJ1.js +1 -0
- package/client/dist/assets/dialog-CdWCb02j.js +45 -0
- package/client/dist/assets/dist-js-CWeUdf5Z.js +1 -0
- package/client/dist/assets/dist-js-Rlh4FLH5.js +1 -0
- package/client/dist/assets/event-Bt7f3wXe.js +1 -0
- package/client/dist/assets/file-text-BcLCfcWZ.js +1 -0
- package/client/dist/assets/{formatDistanceToNow-BOuXgebD.js → formatDistanceToNow-C0ImhVKp.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-EjGOpn4b.js → getTimezoneOffsetInMilliseconds-owhDdTXs.js} +1 -1
- package/client/dist/assets/git-refresh-wTW0rO0Z.js +1 -0
- package/client/dist/assets/i18n-DwgvwTnx.js +2 -0
- package/client/dist/assets/index-DhjjFyU5.js +66 -0
- package/client/dist/assets/index-xBFsOEiJ.css +2 -0
- package/client/dist/assets/jira-api-CcZMcEN5.js +1 -0
- package/client/dist/assets/loops-B7YpGizO.js +1 -0
- package/client/dist/assets/{loops-CMMQOw2P.js → loops-BomD2J-h.js} +1 -1
- package/client/dist/assets/{loops-B2_97D26.js → loops-BrQHSQw3.js} +1 -1
- package/client/dist/assets/{loops-BwxCndZk.js → loops-BvBwvTPA.js} +1 -1
- package/client/dist/assets/{loops-BISOkuCu.js → loops-CODk6Slz.js} +1 -1
- package/client/dist/assets/{loops-CD19kEzq.js → loops-DC6k-Ym-.js} +1 -1
- package/client/dist/assets/{loops-aEb4RxN2.js → loops-DSEg8-uY.js} +1 -1
- package/client/dist/assets/{loops-Bo6w_peZ.js → loops-DhCoXD_v.js} +1 -1
- package/client/dist/assets/narration-4Z2VUhZw.js +1 -0
- package/client/dist/assets/narration-BGuiEDj3.js +1 -0
- package/client/dist/assets/narration-BuSoqRmv.js +1 -0
- package/client/dist/assets/narration-CB9t29fy.js +1 -0
- package/client/dist/assets/narration-CU3x8FHD.js +1 -0
- package/client/dist/assets/narration-CWeXhfP5.js +1 -0
- package/client/dist/assets/narration-DRaDnhT5.js +1 -0
- package/client/dist/assets/narration-DtEbQgCw.js +1 -0
- package/client/dist/assets/packet-B1IxaL5f.js +1 -0
- package/client/dist/assets/packet-Bh5kkbiB.js +1 -0
- package/client/dist/assets/packet-By8fqYxW.js +1 -0
- package/client/dist/assets/packet-CZdvA44E.js +1 -0
- package/client/dist/assets/packet-CrjHRQCP.js +1 -0
- package/client/dist/assets/packet-Dwi2pTtx.js +1 -0
- package/client/dist/assets/packet-_-ZIa0IR.js +1 -0
- package/client/dist/assets/packet-n0W6E1Hn.js +1 -0
- package/client/dist/assets/save-B6wGWwBR.js +1 -0
- package/client/dist/assets/settings-Bd0x4TBa.js +1 -0
- package/client/dist/assets/settings-C1-7cXzC.js +1 -0
- package/client/dist/assets/settings-CAFaT38b.js +1 -0
- package/client/dist/assets/settings-Ct3v1nW2.js +1 -0
- package/client/dist/assets/settings-DHh7_IF5.js +1 -0
- package/client/dist/assets/settings-DQXAmdcv.js +1 -0
- package/client/dist/assets/settings-guzlhpup.js +1 -0
- package/client/dist/assets/settings-sZfTPMEH.js +1 -0
- package/client/dist/assets/{spending-BpYPsevQ.js → spending-BQX3P7Cq.js} +1 -1
- package/client/dist/assets/useDesktop-BzX769yH.js +1 -0
- package/client/dist/index.html +29 -31
- package/docs/internals/embedded-browser-native-webview-evaluation.md +161 -0
- package/docs/internals/review-packet.md +212 -0
- package/package.json +1 -1
- package/server/dist/accept-ladder.js +61 -0
- package/server/dist/agent-chat-manager.js +22 -1
- package/server/dist/agent-defaults.js +359 -0
- package/server/dist/agent-operator-prompt.js +13 -5
- package/server/dist/browser-playwright.js +39 -2
- package/server/dist/db.js +38 -0
- package/server/dist/delivery-evidence.js +242 -0
- package/server/dist/desktop-router.js +23 -0
- package/server/dist/feature-flags.js +23 -0
- package/server/dist/loop-command-catalog.js +33 -0
- package/server/dist/loop-executors.js +12 -2
- package/server/dist/loop-factory.js +16 -1
- package/server/dist/loops-router.js +2 -0
- package/server/dist/mcp/tools/rails.js +16 -0
- package/server/dist/project-registry.js +33 -1
- package/server/dist/project-router-loop-runs.js +27 -2
- package/server/dist/providers/claude-adapter.js +39 -2
- package/server/dist/queue-manager.js +47 -4
- package/server/dist/rail-isolated-launch.js +101 -2
- package/server/dist/rail-pr-store.js +40 -3
- package/server/dist/rails-router.js +124 -3
- package/server/dist/review-packet.js +327 -0
- package/server/dist/revision-seed.js +73 -0
- package/server/dist/run-duration-stats.js +71 -0
- package/server/dist/stuck-run-detector.js +153 -0
- package/server/dist/telemetry-receiver.js +22 -0
- package/client/dist/assets/ActivityFeedPage-B4yaoZsD.js +0 -1
- package/client/dist/assets/AgentBrowserCapture-Bhykbzog.js +0 -1
- package/client/dist/assets/AgentModeAnalyticsPane-prvv23b7.js +0 -2
- package/client/dist/assets/AgentModeCodePane-BmxySwPP.js +0 -2
- package/client/dist/assets/AgentModeJobsPane-BvW6tTmU.js +0 -1
- package/client/dist/assets/AgentsPage-DinMMQPm.js +0 -87
- package/client/dist/assets/AnalyticsPage-d5GmoZY2.js +0 -1
- package/client/dist/assets/CodePage-Cgbj4lpK.js +0 -2
- package/client/dist/assets/DesktopAnalyticsPage-DbbZi6fw.js +0 -1
- package/client/dist/assets/DocsDialog-B87oF3aC.js +0 -11
- package/client/dist/assets/DocsPage-CqJ-yRkh.js +0 -11
- package/client/dist/assets/IntegrationsPage-Bs00_tl4.js +0 -1
- package/client/dist/assets/InteractiveJobComposer-nPGDKuCk.js +0 -19
- package/client/dist/assets/JobDetailModal-duouGMzi.js +0 -1
- package/client/dist/assets/JobDetailPage-Cw3Hu579.js +0 -1
- package/client/dist/assets/JobsPage-DYyV-qWT.js +0 -1
- package/client/dist/assets/LoopPreviewModal-Md6KF7rU.js +0 -1
- package/client/dist/assets/LoopsPage-BKNiwsy1.js +0 -1
- package/client/dist/assets/MinimizedChatsContext-CHsu3NCX.js +0 -1
- package/client/dist/assets/PluginsPage-r682uE6n.js +0 -2
- package/client/dist/assets/TemplatePreviewModal-DcgWnn23.js +0 -1
- package/client/dist/assets/TicketDetailModal-BPaqyvrs.js +0 -14
- package/client/dist/assets/agent-Bd-q3Wqp.js +0 -1
- package/client/dist/assets/agent-BlvdW4Hv.js +0 -1
- package/client/dist/assets/agent-C-KQhxeM.js +0 -1
- package/client/dist/assets/agent-Ca15w5eu.js +0 -1
- package/client/dist/assets/agent-D6fKaoMd.js +0 -1
- package/client/dist/assets/agent-HFMYMTbB.js +0 -1
- package/client/dist/assets/agent-QIEbkBjg.js +0 -1
- package/client/dist/assets/agent-mTCsAbCl.js +0 -1
- package/client/dist/assets/api-B2M7CtDw.js +0 -1
- package/client/dist/assets/arrow-left-CpIR4j7G.js +0 -1
- package/client/dist/assets/arrow-up-right-9DoMn7fD.js +0 -1
- package/client/dist/assets/browser-CXUcnTMI.js +0 -1
- package/client/dist/assets/button-BUIIBQPT.js +0 -1
- package/client/dist/assets/chevron-right-CViMxucY.js +0 -1
- package/client/dist/assets/dashboard-BHRqsGCV.js +0 -1
- package/client/dist/assets/dashboard-Bfr90vOl.js +0 -1
- package/client/dist/assets/dashboard-CTsM8x3m.js +0 -1
- package/client/dist/assets/dashboard-CqC6wX0h.js +0 -1
- package/client/dist/assets/dashboard-CuZtAszc.js +0 -1
- package/client/dist/assets/dashboard-DAcqIc-n.js +0 -1
- package/client/dist/assets/dashboard-DekiV-86.js +0 -1
- package/client/dist/assets/dashboard-DoDczc5S.js +0 -1
- package/client/dist/assets/dialog-CzIekCw0.js +0 -45
- package/client/dist/assets/dist-Cez5CRkX.js +0 -1
- package/client/dist/assets/dist-js-B1s_CM_v.js +0 -1
- package/client/dist/assets/dist-js-BNETKUa_.js +0 -1
- package/client/dist/assets/external-link-BcU5EWZp.js +0 -1
- package/client/dist/assets/file-text-CFT-xXKN.js +0 -1
- package/client/dist/assets/i18n-nJQvD5zJ.js +0 -2
- package/client/dist/assets/index-DCN5QbDm.css +0 -2
- package/client/dist/assets/index-DdC_faQv.js +0 -63
- package/client/dist/assets/jira-api-Bhixwn-d.js +0 -1
- package/client/dist/assets/loops-CnxqL8IL.js +0 -1
- package/client/dist/assets/save-28sIlCdr.js +0 -1
- package/client/dist/assets/settings-BveOJoaI.js +0 -1
- package/client/dist/assets/settings-Cj1C6F5k.js +0 -1
- package/client/dist/assets/settings-CuVwOpe0.js +0 -1
- package/client/dist/assets/settings-D5X4DfRc.js +0 -1
- package/client/dist/assets/settings-DXwxe-BW.js +0 -1
- package/client/dist/assets/settings-Di81Ba_V.js +0 -1
- package/client/dist/assets/settings-jbLjCYC_.js +0 -1
- package/client/dist/assets/settings-wIZNPFnn.js +0 -1
- package/client/dist/assets/useBrowserCaptureSession-BBXBN0Na.js +0 -1
- package/client/dist/assets/useDesktop-B6Du2N83.js +0 -1
- package/client/dist/assets/useSharedWebSocket-DK96ctb7.js +0 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DRIFT_REVISION_COUNT = exports.DRIFT_COST_SHARE = exports.MAX_VERSION_CHAIN = exports.HUMAN_REVIEW_BAND = void 0;
|
|
4
|
+
exports.selectVariant = selectVariant;
|
|
5
|
+
exports.computeDriftNudges = computeDriftNudges;
|
|
6
|
+
exports.composeReviewPacket = composeReviewPacket;
|
|
7
|
+
exports.packetHasUnsourcedNumericClaim = packetHasUnsourcedNumericClaim;
|
|
8
|
+
const delivery_evidence_1 = require("./delivery-evidence");
|
|
9
|
+
const rail_pr_store_1 = require("./rail-pr-store");
|
|
10
|
+
const pr_body_1 = require("./pr-body");
|
|
11
|
+
const ai_invocations_1 = require("./ai-invocations");
|
|
12
|
+
const TEST_FILE_RE = /(\.test\.|\.spec\.|(^|\/)__tests__\/)/;
|
|
13
|
+
function churnForRuns(db, runIds) {
|
|
14
|
+
const ids = [...new Set(runIds.filter((id) => typeof id === 'string' && id.length > 0))];
|
|
15
|
+
if (ids.length === 0)
|
|
16
|
+
return [];
|
|
17
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
18
|
+
try {
|
|
19
|
+
return db.prepare(`
|
|
20
|
+
SELECT job_id, file_path, added_lines, removed_lines
|
|
21
|
+
FROM file_story_contributions
|
|
22
|
+
WHERE job_id IN (${placeholders})
|
|
23
|
+
`).all(...ids);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Variant selection reads ONLY durable outcomes. `no-changes` must win over
|
|
31
|
+
* `success` (a run that changed nothing has nothing to show), and a mixed batch
|
|
32
|
+
* is `partial` rather than a success that quietly hides a failed ticket.
|
|
33
|
+
*/
|
|
34
|
+
function selectVariant(row, units) {
|
|
35
|
+
if (row.decision === 'no_changes' || row.delivery_outcome === 'no_changes' || row.status_code === 'no_changes') {
|
|
36
|
+
return 'no-changes';
|
|
37
|
+
}
|
|
38
|
+
if (row.implementation_outcome === 'failed' || row.decision === 'implementation_failed')
|
|
39
|
+
return 'failed';
|
|
40
|
+
const succeeded = units.filter((unit) => unit.implementationOutcome === 'succeeded' || (unit.implementationOutcome == null && unit.succeeded)).length;
|
|
41
|
+
const failed = units.filter((unit) => unit.implementationOutcome === 'failed' || (unit.implementationOutcome == null && !unit.succeeded)).length;
|
|
42
|
+
if (row.implementation_outcome === 'partially_succeeded' || row.delivery_outcome === 'partial' || (succeeded > 0 && failed > 0)) {
|
|
43
|
+
return 'partial';
|
|
44
|
+
}
|
|
45
|
+
return 'success';
|
|
46
|
+
}
|
|
47
|
+
function headlineFor(variant, succeeded, total) {
|
|
48
|
+
switch (variant) {
|
|
49
|
+
case 'no-changes': return 'headline.noChanges';
|
|
50
|
+
case 'failed': return succeeded > 0 ? 'headline.failedPartial' : 'headline.failed';
|
|
51
|
+
case 'partial': return 'headline.partial';
|
|
52
|
+
default: return total > 1 ? 'headline.successMany' : 'headline.success';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Reviewer band documented by sr-reviewer's schema: 50-69 ⇒ recommend a human. */
|
|
56
|
+
exports.HUMAN_REVIEW_BAND = { min: 50, max: 69 };
|
|
57
|
+
function buildProof(units, evidence, churn, confidence) {
|
|
58
|
+
const proof = [];
|
|
59
|
+
// ── Tier 1: what Specrails measured itself ────────────────────────────────
|
|
60
|
+
const testFiles = [...new Set(churn.filter((row) => TEST_FILE_RE.test(row.file_path)).map((row) => row.file_path))];
|
|
61
|
+
const totalFiles = new Set(churn.map((row) => row.file_path)).size;
|
|
62
|
+
const added = churn.reduce((sum, row) => sum + (row.added_lines || 0), 0);
|
|
63
|
+
const removed = churn.reduce((sum, row) => sum + (row.removed_lines || 0), 0);
|
|
64
|
+
if (totalFiles > 0) {
|
|
65
|
+
proof.push({
|
|
66
|
+
tier: 'app-verified',
|
|
67
|
+
code: 'proof.filesChanged',
|
|
68
|
+
values: { files: totalFiles, added, removed },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
proof.push(testFiles.length > 0
|
|
72
|
+
? { tier: 'app-verified', code: 'proof.testFilesChanged', values: { count: testFiles.length } }
|
|
73
|
+
// The honest negative: silence here would read as "tests fine".
|
|
74
|
+
: { tier: 'app-verified', code: 'proof.noTestFilesChanged' });
|
|
75
|
+
const verifiedBranches = units.filter((unit) => unit.finalSha && unit.finalSha !== unit.initialSha).length;
|
|
76
|
+
if (verifiedBranches > 0) {
|
|
77
|
+
proof.push({ tier: 'app-verified', code: 'proof.commitsRecorded', values: { branches: verifiedBranches } });
|
|
78
|
+
}
|
|
79
|
+
// ── Tier 2: what the agent itself reported ────────────────────────────────
|
|
80
|
+
const sentinels = (evidence ?? []).filter((unit) => unit.sentinel !== 'absent');
|
|
81
|
+
if (sentinels.length === 0) {
|
|
82
|
+
proof.push({ tier: 'ai-reported', code: 'proof.noVerificationReported' });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const failed = sentinels.filter((unit) => unit.sentinel === 'fail');
|
|
86
|
+
if (failed.length > 0) {
|
|
87
|
+
// Failure leads: a packet must never bury a reported failure under a pass.
|
|
88
|
+
proof.push({
|
|
89
|
+
tier: 'ai-reported',
|
|
90
|
+
code: 'proof.verificationFailed',
|
|
91
|
+
values: { count: failed.length },
|
|
92
|
+
...(failed[0].sentinelDetail ? { rawExcerpt: failed[0].sentinelDetail } : {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const passed = sentinels.filter((unit) => unit.sentinel === 'pass');
|
|
96
|
+
if (passed.length > 0) {
|
|
97
|
+
proof.push({ tier: 'ai-reported', code: 'proof.verificationPassed', values: { count: passed.length } });
|
|
98
|
+
}
|
|
99
|
+
const tail = sentinels.map((unit) => unit.verifyTail).find((value) => Boolean(value));
|
|
100
|
+
if (tail)
|
|
101
|
+
proof.push({ tier: 'ai-reported', code: 'proof.verifyOutput', rawExcerpt: tail });
|
|
102
|
+
}
|
|
103
|
+
// ── Tier 3: the reviewer grading its own pipeline ─────────────────────────
|
|
104
|
+
if (confidence) {
|
|
105
|
+
if (confidence.overall !== null) {
|
|
106
|
+
proof.push({
|
|
107
|
+
tier: 'reviewer-score',
|
|
108
|
+
code: 'proof.reviewerScore',
|
|
109
|
+
values: { overall: confidence.overall },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (const [aspect, score] of Object.entries(confidence.aspects)) {
|
|
113
|
+
proof.push({ tier: 'reviewer-score', code: 'proof.reviewerAspect', values: { aspect, score } });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
proof.push({ tier: 'reviewer-score', code: 'proof.reviewerScoreUnavailable' });
|
|
118
|
+
}
|
|
119
|
+
return proof;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* "What to watch out for" is capped and derived from REAL signals only —
|
|
123
|
+
* reviewer flags, the human-review band, and a code-changed-without-tests
|
|
124
|
+
* observation. Confabulated caution would train the user to ignore the section.
|
|
125
|
+
*/
|
|
126
|
+
function buildWatchOut(confidence, churn, variant) {
|
|
127
|
+
const out = [];
|
|
128
|
+
if (confidence?.overall !== null && confidence?.overall !== undefined) {
|
|
129
|
+
if (confidence.overall >= exports.HUMAN_REVIEW_BAND.min && confidence.overall <= exports.HUMAN_REVIEW_BAND.max) {
|
|
130
|
+
out.push({ tier: 'reviewer-score', code: 'watch.humanReviewRecommended', values: { overall: confidence.overall } });
|
|
131
|
+
}
|
|
132
|
+
else if (confidence.overall < exports.HUMAN_REVIEW_BAND.min) {
|
|
133
|
+
out.push({ tier: 'reviewer-score', code: 'watch.lowConfidence', values: { overall: confidence.overall } });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const flag of (confidence?.flags ?? []).slice(0, 3)) {
|
|
137
|
+
out.push({ tier: 'reviewer-score', code: 'watch.reviewerFlag', rawExcerpt: flag });
|
|
138
|
+
}
|
|
139
|
+
const codeFiles = churn.filter((row) => !TEST_FILE_RE.test(row.file_path));
|
|
140
|
+
const testFiles = churn.filter((row) => TEST_FILE_RE.test(row.file_path));
|
|
141
|
+
if (out.length < 3 && variant !== 'no-changes' && codeFiles.length > 0 && testFiles.length === 0) {
|
|
142
|
+
out.push({ tier: 'app-verified', code: 'watch.noTestsForChangedCode', values: { files: codeFiles.length } });
|
|
143
|
+
}
|
|
144
|
+
return out.slice(0, 3);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Walk the supersession chain back to the original build. Bounded so a corrupted
|
|
148
|
+
* lineage cannot spin, and tolerant of a missing ancestor (an old row may have
|
|
149
|
+
* been pruned) — the chain simply starts where the data does.
|
|
150
|
+
*/
|
|
151
|
+
exports.MAX_VERSION_CHAIN = 25;
|
|
152
|
+
function versionChain(db, row) {
|
|
153
|
+
const chain = [row];
|
|
154
|
+
const seen = new Set([row.id]);
|
|
155
|
+
let cursor = row;
|
|
156
|
+
while (chain.length < exports.MAX_VERSION_CHAIN && cursor.revision_of) {
|
|
157
|
+
const previous = (0, rail_pr_store_1.getPrDelivery)(db, cursor.revision_of);
|
|
158
|
+
if (!previous || seen.has(previous.id))
|
|
159
|
+
break;
|
|
160
|
+
seen.add(previous.id);
|
|
161
|
+
chain.push(previous);
|
|
162
|
+
cursor = previous;
|
|
163
|
+
}
|
|
164
|
+
return chain.reverse(); // oldest first: v1 … vN
|
|
165
|
+
}
|
|
166
|
+
/** Fraction of the original build's cost above which revisions are worth flagging. */
|
|
167
|
+
exports.DRIFT_COST_SHARE = 0.5;
|
|
168
|
+
/** Revision count backstop — advisory, never a block. */
|
|
169
|
+
exports.DRIFT_REVISION_COUNT = 3;
|
|
170
|
+
/**
|
|
171
|
+
* Reshape-the-spec nudges. Every trigger is a real measurement (cumulative cost
|
|
172
|
+
* vs the original build, churn landing outside the original file set, revision
|
|
173
|
+
* depth) so the banner can show its own numbers instead of asserting a vibe.
|
|
174
|
+
* A hard count alone was rejected: it treats a 40-cent typo fix and a nine-dollar
|
|
175
|
+
* architectural thrash identically.
|
|
176
|
+
*/
|
|
177
|
+
function computeDriftNudges(input) {
|
|
178
|
+
const nudges = [];
|
|
179
|
+
const [original, ...revisions] = input.versions;
|
|
180
|
+
if (revisions.length === 0)
|
|
181
|
+
return nudges;
|
|
182
|
+
const originalCost = original?.costUsd ?? null;
|
|
183
|
+
const revisionCost = revisions.reduce((sum, version) => sum + (version.costUsd ?? 0), 0);
|
|
184
|
+
if (originalCost !== null && originalCost > 0 && revisionCost > originalCost * exports.DRIFT_COST_SHARE) {
|
|
185
|
+
nudges.push({
|
|
186
|
+
code: 'drift.costShare',
|
|
187
|
+
values: {
|
|
188
|
+
revisions: revisions.length,
|
|
189
|
+
revisionCost: revisionCost.toFixed(2),
|
|
190
|
+
originalCost: originalCost.toFixed(2),
|
|
191
|
+
share: Math.round((revisionCost / originalCost) * 100),
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (input.originalFileSet.size > 0 && input.currentFiles.size > 0) {
|
|
196
|
+
const outside = [...input.currentFiles].filter((file) => !input.originalFileSet.has(file));
|
|
197
|
+
if (outside.length * 2 > input.currentFiles.size) {
|
|
198
|
+
nudges.push({ code: 'drift.outOfScopeChurn', values: { files: outside.length, total: input.currentFiles.size } });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (revisions.length >= exports.DRIFT_REVISION_COUNT) {
|
|
202
|
+
nudges.push({ code: 'drift.revisionCount', values: { revisions: revisions.length } });
|
|
203
|
+
}
|
|
204
|
+
return nudges;
|
|
205
|
+
}
|
|
206
|
+
function composeReviewPacket({ db, row }) {
|
|
207
|
+
const ticketIds = safeParse(row.ticket_ids, []);
|
|
208
|
+
const units = safeParse(row.branches, []);
|
|
209
|
+
const runIds = safeParse(row.run_ids, []);
|
|
210
|
+
const snapshot = (0, rail_pr_store_1.readSpecSnapshot)(row.spec_snapshot);
|
|
211
|
+
const evidence = (0, delivery_evidence_1.readSettleEvidence)(row.settle_evidence);
|
|
212
|
+
const churn = churnForRuns(db, runIds);
|
|
213
|
+
const variant = selectVariant(row, units);
|
|
214
|
+
const confidence = (evidence?.units ?? [])
|
|
215
|
+
.map((unit) => unit.confidence)
|
|
216
|
+
.find((score) => score !== null) ?? null;
|
|
217
|
+
const sections = ticketIds.map((ticketId) => {
|
|
218
|
+
const snap = snapshot?.find((entry) => entry.ticketId === ticketId) ?? null;
|
|
219
|
+
const narrative = (0, pr_body_1.extractSpecNarrative)(snap?.description ?? null);
|
|
220
|
+
const unitRows = units.filter((unit) => unit.ticketId === ticketId);
|
|
221
|
+
const unitRunIds = [...new Set(unitRows.map((unit) => unit.runId).filter((id) => Boolean(id)))];
|
|
222
|
+
// A run that covered several tickets (a batch collapsed into one checkout)
|
|
223
|
+
// cannot have its files split per ticket: provenance keys them to the run's
|
|
224
|
+
// primary ticket. Report null rather than repeat delivery totals per section.
|
|
225
|
+
const sharedRun = unitRunIds.some((runId) => new Set(units.filter((unit) => unit.runId === runId).map((unit) => unit.ticketId)).size > 1);
|
|
226
|
+
const unitChurn = !sharedRun && unitRunIds.length > 0
|
|
227
|
+
? churn.filter((row) => row.job_id != null && unitRunIds.includes(row.job_id))
|
|
228
|
+
: null;
|
|
229
|
+
return {
|
|
230
|
+
ticketId,
|
|
231
|
+
title: snap?.title ?? null,
|
|
232
|
+
problem: narrative.problem,
|
|
233
|
+
solution: narrative.solution,
|
|
234
|
+
labels: snap?.labels ?? [],
|
|
235
|
+
implementationOutcome: unitRows[0]?.implementationOutcome ?? null,
|
|
236
|
+
deliveryOutcome: unitRows[0]?.deliveryOutcome ?? null,
|
|
237
|
+
changed: unitRows[0]?.changed ?? null,
|
|
238
|
+
churn: unitChurn === null ? null : {
|
|
239
|
+
filesTouched: new Set(unitChurn.map((c) => c.file_path)).size,
|
|
240
|
+
addedLines: unitChurn.reduce((sum, c) => sum + (c.added_lines || 0), 0),
|
|
241
|
+
removedLines: unitChurn.reduce((sum, c) => sum + (c.removed_lines || 0), 0),
|
|
242
|
+
testFilesTouched: [...new Set(unitChurn.filter((c) => TEST_FILE_RE.test(c.file_path)).map((c) => c.file_path))],
|
|
243
|
+
},
|
|
244
|
+
runIds: unitRunIds,
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
const succeededCount = units.length > 0
|
|
248
|
+
? units.filter((unit) => unit.implementationOutcome === 'succeeded' || (unit.implementationOutcome == null && unit.succeeded)).length
|
|
249
|
+
: row.implementation_outcome === 'failed' ? 0 : ticketIds.length;
|
|
250
|
+
const failedCount = units.length > 0
|
|
251
|
+
? units.filter((unit) => unit.implementationOutcome === 'failed' || (unit.implementationOutcome == null && !unit.succeeded)).length
|
|
252
|
+
: row.implementation_outcome === 'failed' ? ticketIds.length : 0;
|
|
253
|
+
const costSum = (0, ai_invocations_1.sumInvocationCostForRuns)(db, runIds);
|
|
254
|
+
const chain = versionChain(db, row);
|
|
255
|
+
const versions = chain.map((entry, index) => {
|
|
256
|
+
const entryRunIds = safeParse(entry.run_ids, []);
|
|
257
|
+
const entryCost = (0, ai_invocations_1.sumInvocationCostForRuns)(db, entryRunIds);
|
|
258
|
+
return {
|
|
259
|
+
prDeliveryId: entry.id,
|
|
260
|
+
version: index + 1,
|
|
261
|
+
revisionNote: entry.revision_note,
|
|
262
|
+
decision: entry.decision,
|
|
263
|
+
costUsd: entryCost?.totalUsd ?? null,
|
|
264
|
+
costEstimated: entryCost?.estimated ?? false,
|
|
265
|
+
current: entry.id === row.id,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
const chainCosts = versions.map((version) => version.costUsd).filter((value) => value !== null);
|
|
269
|
+
// Churn comparison uses the ORIGINAL generation's files as the scope baseline:
|
|
270
|
+
// a revision touching mostly new files is the honest signal that the spec no
|
|
271
|
+
// longer describes the work.
|
|
272
|
+
const originalFiles = chain.length > 1
|
|
273
|
+
? new Set(churnForRuns(db, safeParse(chain[0].run_ids, [])).map((entry) => entry.file_path))
|
|
274
|
+
: new Set();
|
|
275
|
+
const driftNudges = computeDriftNudges({
|
|
276
|
+
versions,
|
|
277
|
+
originalFileSet: originalFiles,
|
|
278
|
+
currentFiles: new Set(churn.map((entry) => entry.file_path)),
|
|
279
|
+
});
|
|
280
|
+
return {
|
|
281
|
+
schemaVersion: 1,
|
|
282
|
+
prDeliveryId: row.id,
|
|
283
|
+
railIndex: row.rail_index,
|
|
284
|
+
variant,
|
|
285
|
+
decision: row.decision,
|
|
286
|
+
statusCode: row.status_code,
|
|
287
|
+
headlineCode: headlineFor(variant, succeededCount, ticketIds.length),
|
|
288
|
+
ticketIds,
|
|
289
|
+
baseBranch: row.base_branch,
|
|
290
|
+
loopName: row.loop_name,
|
|
291
|
+
prUrl: row.pr_url,
|
|
292
|
+
prNumber: row.pr_number,
|
|
293
|
+
succeededCount,
|
|
294
|
+
failedCount,
|
|
295
|
+
totalCount: units.length > 0 ? new Set(units.map((u) => u.ticketId)).size : ticketIds.length,
|
|
296
|
+
sections,
|
|
297
|
+
proof: buildProof(units, evidence?.units ?? null, churn, confidence),
|
|
298
|
+
watchOut: buildWatchOut(confidence, churn, variant),
|
|
299
|
+
confidence,
|
|
300
|
+
cost: { totalUsd: costSum?.totalUsd ?? null, estimated: costSum?.estimated ?? false },
|
|
301
|
+
evidenceUnavailable: evidence === null || evidence.harvest === 'failed',
|
|
302
|
+
runIds,
|
|
303
|
+
supersedesDeliveryId: row.supersedes_delivery_id,
|
|
304
|
+
revisionNote: row.revision_note,
|
|
305
|
+
versions,
|
|
306
|
+
chainCostUsd: chainCosts.length > 0 ? chainCosts.reduce((sum, value) => sum + value, 0) : null,
|
|
307
|
+
chainCostEstimated: versions.some((version) => version.costEstimated),
|
|
308
|
+
driftNudges,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Assert-by-construction guard used in tests and by the route: no packet field
|
|
312
|
+
* may carry a numeric verification claim without a structured source. */
|
|
313
|
+
function packetHasUnsourcedNumericClaim(packet) {
|
|
314
|
+
return packet.proof.some((item) => item.tier === 'ai-reported'
|
|
315
|
+
&& Object.keys(item.values ?? {}).some((key) => key === 'tests' || key === 'passed' || key === 'failedTests'));
|
|
316
|
+
}
|
|
317
|
+
function safeParse(raw, fallback) {
|
|
318
|
+
if (!raw)
|
|
319
|
+
return fallback;
|
|
320
|
+
try {
|
|
321
|
+
const parsed = JSON.parse(raw);
|
|
322
|
+
return parsed ?? fallback;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return fallback;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SEED_EVIDENCE_CAP = exports.SEED_DIFF_CAP = exports.SEED_SPEC_BODY_CAP = void 0;
|
|
4
|
+
exports.buildRevisionSeed = buildRevisionSeed;
|
|
5
|
+
/** Bounds keep the seed a briefing, not a transcript. */
|
|
6
|
+
exports.SEED_SPEC_BODY_CAP = 4000;
|
|
7
|
+
exports.SEED_DIFF_CAP = 4000;
|
|
8
|
+
exports.SEED_EVIDENCE_CAP = 1200;
|
|
9
|
+
function clamp(value, cap) {
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
return trimmed.length <= cap ? trimmed : `${trimmed.slice(0, cap)}\n…(truncated)`;
|
|
12
|
+
}
|
|
13
|
+
/** One-line digest of what the previous run's verification actually reported. */
|
|
14
|
+
function evidenceLine(evidence) {
|
|
15
|
+
if (!evidence || evidence.units.length === 0)
|
|
16
|
+
return 'No verification evidence was captured for the previous run.';
|
|
17
|
+
const failed = evidence.units.filter((unit) => unit.sentinel === 'fail');
|
|
18
|
+
const passed = evidence.units.filter((unit) => unit.sentinel === 'pass');
|
|
19
|
+
const parts = [];
|
|
20
|
+
if (failed.length > 0) {
|
|
21
|
+
// Failure first: a revision briefing must not bury it behind a pass.
|
|
22
|
+
const detail = failed.map((unit) => unit.sentinelDetail).find(Boolean);
|
|
23
|
+
parts.push(`The previous run REPORTED FAILED verification${detail ? ` (${clamp(detail, 200)})` : ''}.`);
|
|
24
|
+
}
|
|
25
|
+
if (passed.length > 0)
|
|
26
|
+
parts.push('The previous run reported its verification passed.');
|
|
27
|
+
if (parts.length === 0)
|
|
28
|
+
parts.push('The previous run did not report a verification verdict.');
|
|
29
|
+
const score = evidence.units.map((unit) => unit.confidence?.overall).find((value) => typeof value === 'number');
|
|
30
|
+
if (typeof score === 'number')
|
|
31
|
+
parts.push(`Its reviewer scored the work ${score}/100.`);
|
|
32
|
+
return parts.join(' ');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The briefing a revision run is seeded with. Deterministic (same input ⇒ same
|
|
36
|
+
* text) so it is cache-friendly and testable, and honest: it never claims the
|
|
37
|
+
* previous run verified anything the evidence does not say.
|
|
38
|
+
*/
|
|
39
|
+
function buildRevisionSeed(input) {
|
|
40
|
+
const specs = (input.specSnapshot ?? []).length > 0
|
|
41
|
+
? (input.specSnapshot ?? []).map((entry) => [
|
|
42
|
+
`### #${entry.ticketId} ${entry.title ?? '(untitled)'}`,
|
|
43
|
+
entry.description ? clamp(entry.description, exports.SEED_SPEC_BODY_CAP) : '(no description recorded)',
|
|
44
|
+
].join('\n')).join('\n\n')
|
|
45
|
+
: input.ticketIds.map((id) => `### #${id}\n(spec text was not captured at launch)`).join('\n\n');
|
|
46
|
+
return [
|
|
47
|
+
'## Revision briefing',
|
|
48
|
+
'',
|
|
49
|
+
`This is revision ${input.revisionNumber} of work that has ALREADY been delivered for the spec(s) below.`,
|
|
50
|
+
'The code exists on the branch listed here. Do NOT start over, re-plan, or re-implement it.',
|
|
51
|
+
'',
|
|
52
|
+
'## What the user asked to change',
|
|
53
|
+
'',
|
|
54
|
+
clamp(input.note, exports.SEED_SPEC_BODY_CAP),
|
|
55
|
+
'',
|
|
56
|
+
'## The original request (frozen at launch)',
|
|
57
|
+
'',
|
|
58
|
+
specs,
|
|
59
|
+
'',
|
|
60
|
+
'## Where the existing work lives',
|
|
61
|
+
'',
|
|
62
|
+
input.branches.length > 0
|
|
63
|
+
? `Branch(es): ${input.branches.join(', ')} (based on ${input.baseBranch}).`
|
|
64
|
+
: `Base branch: ${input.baseBranch}.`,
|
|
65
|
+
...(input.branchDiffSummary
|
|
66
|
+
? ['', 'Already changed on that branch:', '', clamp(input.branchDiffSummary, exports.SEED_DIFF_CAP)]
|
|
67
|
+
: []),
|
|
68
|
+
'',
|
|
69
|
+
'## What the previous run reported',
|
|
70
|
+
'',
|
|
71
|
+
clamp(evidenceLine(input.evidence), exports.SEED_EVIDENCE_CAP),
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MIN_DURATION_SAMPLES = void 0;
|
|
4
|
+
exports.percentileMs = percentileMs;
|
|
5
|
+
exports.rangeFromSamples = rangeFromSamples;
|
|
6
|
+
exports.getLoopDurationRange = getLoopDurationRange;
|
|
7
|
+
exports.jobCommandShape = jobCommandShape;
|
|
8
|
+
exports.getJobCommandDurationRange = getJobCommandDurationRange;
|
|
9
|
+
/** Fewer settled runs than this and no band is returned. */
|
|
10
|
+
exports.MIN_DURATION_SAMPLES = 5;
|
|
11
|
+
/**
|
|
12
|
+
* Nearest-rank percentile over an ASCENDING sample. Deliberately not
|
|
13
|
+
* interpolated: an interpolated value is a number no run actually took, and
|
|
14
|
+
* every figure this module emits must be traceable to a real measurement.
|
|
15
|
+
*/
|
|
16
|
+
function percentileMs(sortedAsc, fraction) {
|
|
17
|
+
if (sortedAsc.length === 0)
|
|
18
|
+
return 0;
|
|
19
|
+
const rank = Math.ceil(fraction * sortedAsc.length);
|
|
20
|
+
const index = Math.min(sortedAsc.length - 1, Math.max(0, rank - 1));
|
|
21
|
+
return sortedAsc[index];
|
|
22
|
+
}
|
|
23
|
+
function rangeFromSamples(samplesMs) {
|
|
24
|
+
const usable = samplesMs.filter((ms) => Number.isFinite(ms) && ms > 0).sort((a, b) => a - b);
|
|
25
|
+
if (usable.length < exports.MIN_DURATION_SAMPLES)
|
|
26
|
+
return null;
|
|
27
|
+
return {
|
|
28
|
+
p25Ms: percentileMs(usable, 0.25),
|
|
29
|
+
medianMs: percentileMs(usable, 0.5),
|
|
30
|
+
p75Ms: percentileMs(usable, 0.75),
|
|
31
|
+
sampleCount: usable.length,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Cap the history window so a long-lived project's ancient runs (different
|
|
35
|
+
* codebase, different model) cannot skew the band a user is shown today. */
|
|
36
|
+
const HISTORY_LIMIT = 50;
|
|
37
|
+
/** Measured band for a loop id, or null below the sample floor. */
|
|
38
|
+
function getLoopDurationRange(db, projectId, loopId) {
|
|
39
|
+
const rows = db.prepare(`
|
|
40
|
+
SELECT total_duration_ms AS ms
|
|
41
|
+
FROM loop_runs
|
|
42
|
+
WHERE project_id = ? AND loop_id = ? AND finished_at IS NOT NULL AND total_duration_ms > 0
|
|
43
|
+
ORDER BY started_at DESC
|
|
44
|
+
LIMIT ?
|
|
45
|
+
`).all(projectId, loopId, HISTORY_LIMIT);
|
|
46
|
+
return rangeFromSamples(rows.map((row) => row.ms));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Commands carry per-run arguments (`loop:<uuid>`, `/specrails:implement #12`),
|
|
50
|
+
* so history is grouped by a stable SHAPE rather than the literal string:
|
|
51
|
+
* the command's leading token with ticket refs and ids stripped.
|
|
52
|
+
*/
|
|
53
|
+
function jobCommandShape(command) {
|
|
54
|
+
const head = command.trim().split(/\s+/)[0] ?? '';
|
|
55
|
+
if (head.startsWith('loop:'))
|
|
56
|
+
return 'loop:';
|
|
57
|
+
return head;
|
|
58
|
+
}
|
|
59
|
+
/** Measured band for a job command shape, or null below the sample floor. */
|
|
60
|
+
function getJobCommandDurationRange(db, commandShape) {
|
|
61
|
+
const rows = db.prepare(`
|
|
62
|
+
SELECT duration_ms AS ms, command
|
|
63
|
+
FROM jobs
|
|
64
|
+
WHERE status = 'completed' AND duration_ms > 0
|
|
65
|
+
ORDER BY started_at DESC
|
|
66
|
+
LIMIT 500
|
|
67
|
+
`).all();
|
|
68
|
+
return rangeFromSamples(rows.filter((row) => jobCommandShape(row.command ?? '') === commandShape)
|
|
69
|
+
.slice(0, HISTORY_LIMIT)
|
|
70
|
+
.map((row) => row.ms));
|
|
71
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StuckRunDetector = exports.STUCK_SWEEP_INTERVAL_MS = exports.STUCK_FLOOR_MS = void 0;
|
|
4
|
+
exports.resolveStuckThresholdMs = resolveStuckThresholdMs;
|
|
5
|
+
exports.findStuckCandidates = findStuckCandidates;
|
|
6
|
+
const loop_runs_store_1 = require("./loop-runs-store");
|
|
7
|
+
/** Never notify sooner than this, however the threshold is configured. */
|
|
8
|
+
exports.STUCK_FLOOR_MS = 10 * 60 * 1000;
|
|
9
|
+
/** How often the sweep runs while the server is up. */
|
|
10
|
+
exports.STUCK_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
11
|
+
/**
|
|
12
|
+
* `SPECRAILS_STUCK_THRESHOLD_MS` raises (never lowers) the floor; `0`/`false`/
|
|
13
|
+
* `off` disables detection entirely.
|
|
14
|
+
*/
|
|
15
|
+
function resolveStuckThresholdMs(env = process.env) {
|
|
16
|
+
const raw = env.SPECRAILS_STUCK_THRESHOLD_MS;
|
|
17
|
+
if (raw === undefined || raw === '')
|
|
18
|
+
return exports.STUCK_FLOOR_MS;
|
|
19
|
+
const normalized = raw.trim().toLowerCase();
|
|
20
|
+
if (normalized === '0' || normalized === 'false' || normalized === 'off')
|
|
21
|
+
return null;
|
|
22
|
+
const parsed = Number(normalized);
|
|
23
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
24
|
+
return exports.STUCK_FLOOR_MS;
|
|
25
|
+
return Math.max(exports.STUCK_FLOOR_MS, Math.trunc(parsed));
|
|
26
|
+
}
|
|
27
|
+
function lastActivityMs(payload) {
|
|
28
|
+
const candidates = [payload.lastActivityAtMs, payload.activeTurnStartedAtMs]
|
|
29
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
30
|
+
return candidates.length > 0 ? Math.max(...candidates) : null;
|
|
31
|
+
}
|
|
32
|
+
function defaultIsRunActive(db, runId) {
|
|
33
|
+
const row = db.prepare(`SELECT status FROM loop_runs WHERE id = ?`).get(runId);
|
|
34
|
+
return row?.status === 'running';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One pass over the persisted step checkpoints. Pure with respect to the
|
|
38
|
+
* database (reads only) so the caller owns broadcasting and episode memory.
|
|
39
|
+
*/
|
|
40
|
+
function findStuckCandidates(io) {
|
|
41
|
+
const threshold = io.thresholdMs === undefined ? resolveStuckThresholdMs() : io.thresholdMs;
|
|
42
|
+
if (threshold === null)
|
|
43
|
+
return [];
|
|
44
|
+
const now = io.now?.() ?? Date.now();
|
|
45
|
+
const isRunActive = io.isRunActive ?? ((runId) => defaultIsRunActive(io.db, runId));
|
|
46
|
+
const out = [];
|
|
47
|
+
let rows;
|
|
48
|
+
try {
|
|
49
|
+
rows = (0, loop_runs_store_1.listLoopStepRecoveries)(io.db);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
let payload;
|
|
56
|
+
try {
|
|
57
|
+
payload = JSON.parse(row.payload);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const last = lastActivityMs(payload);
|
|
63
|
+
if (last === null)
|
|
64
|
+
continue;
|
|
65
|
+
const staleMs = now - last;
|
|
66
|
+
if (staleMs < threshold)
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
if (!isRunActive(row.run_id))
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out.push({
|
|
76
|
+
runId: row.run_id,
|
|
77
|
+
stepKey: row.step_key,
|
|
78
|
+
projectId: payload.projectId,
|
|
79
|
+
staleMs,
|
|
80
|
+
episode: `${row.run_id}:${row.step_key}:${last}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Owns episode memory across sweeps and emits at most one message per stall.
|
|
87
|
+
* Constructed per project context so the broadcast is already project-scoped.
|
|
88
|
+
*/
|
|
89
|
+
class StuckRunDetector {
|
|
90
|
+
projectId;
|
|
91
|
+
io;
|
|
92
|
+
broadcast;
|
|
93
|
+
notified = new Set();
|
|
94
|
+
timer = null;
|
|
95
|
+
constructor(projectId, io, broadcast) {
|
|
96
|
+
this.projectId = projectId;
|
|
97
|
+
this.io = io;
|
|
98
|
+
this.broadcast = broadcast;
|
|
99
|
+
}
|
|
100
|
+
/** One sweep; returns the messages actually emitted (for tests/diagnostics). */
|
|
101
|
+
sweep() {
|
|
102
|
+
const candidates = findStuckCandidates(this.io);
|
|
103
|
+
const live = new Set(candidates.map((candidate) => candidate.episode));
|
|
104
|
+
// Forget episodes that ended so a later stall of the same step re-notifies.
|
|
105
|
+
for (const episode of [...this.notified]) {
|
|
106
|
+
if (!live.has(episode))
|
|
107
|
+
this.notified.delete(episode);
|
|
108
|
+
}
|
|
109
|
+
const emitted = [];
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
if (this.notified.has(candidate.episode))
|
|
112
|
+
continue;
|
|
113
|
+
this.notified.add(candidate.episode);
|
|
114
|
+
const message = {
|
|
115
|
+
type: 'job.stuck',
|
|
116
|
+
projectId: this.projectId,
|
|
117
|
+
jobId: candidate.runId,
|
|
118
|
+
stepKey: candidate.stepKey,
|
|
119
|
+
staleMs: candidate.staleMs,
|
|
120
|
+
timestamp: new Date(this.io.now?.() ?? Date.now()).toISOString(),
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
this.broadcast(message);
|
|
124
|
+
emitted.push(message);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// A failed broadcast must not wedge the sweep; the next pass retries
|
|
128
|
+
// because the episode is only remembered after a successful send.
|
|
129
|
+
this.notified.delete(candidate.episode);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return emitted;
|
|
133
|
+
}
|
|
134
|
+
start(intervalMs = exports.STUCK_SWEEP_INTERVAL_MS) {
|
|
135
|
+
if (this.timer)
|
|
136
|
+
return;
|
|
137
|
+
if ((this.io.thresholdMs === undefined ? resolveStuckThresholdMs() : this.io.thresholdMs) === null)
|
|
138
|
+
return;
|
|
139
|
+
this.timer = setInterval(() => {
|
|
140
|
+
try {
|
|
141
|
+
this.sweep();
|
|
142
|
+
}
|
|
143
|
+
catch { /* never let a sweep crash the server */ }
|
|
144
|
+
}, intervalMs);
|
|
145
|
+
this.timer.unref?.();
|
|
146
|
+
}
|
|
147
|
+
stop() {
|
|
148
|
+
if (this.timer)
|
|
149
|
+
clearInterval(this.timer);
|
|
150
|
+
this.timer = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
exports.StuckRunDetector = StuckRunDetector;
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.awaitTelemetryAppendQuiescence = awaitTelemetryAppendQuiescence;
|
|
6
7
|
exports.dropBlobState = dropBlobState;
|
|
7
8
|
exports.dropBlobStatesForProject = dropBlobStatesForProject;
|
|
8
9
|
exports.createTelemetryRouter = createTelemetryRouter;
|
|
@@ -35,6 +36,27 @@ function getBlobState(key) {
|
|
|
35
36
|
function blobKey(projectId, jobId) {
|
|
36
37
|
return `${projectId}:${jobId}`;
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Await full append-queue quiescence (every blob: idle writer + empty queue).
|
|
41
|
+
* Test seam: the ingest handler ACKs 200 while queued gzip appends are still
|
|
42
|
+
* in flight; a suite that removes the telemetry dir in afterEach races them —
|
|
43
|
+
* the orphaned append then console.errors DURING vitest environment teardown
|
|
44
|
+
* ("Closing rpc while onUserConsoleLog was pending", flaked in CI 2026-07-27).
|
|
45
|
+
* Bounded so a wedged writer can never hang a suite.
|
|
46
|
+
*/
|
|
47
|
+
async function awaitTelemetryAppendQuiescence(maxWaitMs = 2000) {
|
|
48
|
+
const deadline = Date.now() + maxWaitMs;
|
|
49
|
+
const busy = () => {
|
|
50
|
+
for (const state of _blobState.values()) {
|
|
51
|
+
if (state.writing || state.queue.length > 0)
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
while (busy() && Date.now() < deadline) {
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
38
60
|
/** Evict the in-memory BlobState for one job (bounds the per-(project,job) leak —
|
|
39
61
|
* the map otherwise grew for the whole process lifetime). */
|
|
40
62
|
function dropBlobState(projectId, jobId) {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{n as t,t as n}from"./jsx-runtime-zu2_FqZY.js";import{o as r}from"./i18n-nJQvD5zJ.js";import{n as i,t as a}from"./createLucideIcon-Bjbbp_KS.js";import{t as o}from"./circle-x-CMYsN4ZH.js";import{t as s}from"./loader-circle-DcygOQpC.js";import{t as c}from"./zap-CHo3aCD1.js";import{r as l}from"./useSharedWebSocket-DK96ctb7.js";import{t as u}from"./api-B2M7CtDw.js";import{r as d}from"./useDesktop-B6Du2N83.js";import{Dt as f,Pt as p}from"./index-DdC_faQv.js";var m=a(`ban`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M4.929 4.929 19.07 19.071`,key:`196cmz`}]]),h=e(t(),1);function g(e){let t=new Set;return e.filter(e=>{let n=`${e.type}:${e.jobId}`;return t.has(n)?!1:(t.add(n),!0)})}function _({activeProjectId:e,limit:t=50}){let[n,i]=(0,h.useState)([]),[a,o]=(0,h.useState)(!1),[s,c]=(0,h.useState)(!0),d=(0,h.useRef)(e);(0,h.useEffect)(()=>{d.current=e},[e]);let{registerHandler:f,unregisterHandler:p}=l();(0,h.useEffect)(()=>{if(!e){i([]),c(!0);return}let n=!1;o(!0),i([]),c(!0);async function r(){let e=u();try{let r=await fetch(`${e}/activity?limit=${t}`);if(!r.ok||n)return;let a=await r.json();if(n)return;i(a),c(a.length===t)}catch{}finally{n||o(!1)}}return r(),()=>{n=!0}},[e,t]);let m=(0,h.useCallback)(async()=>{if(a||!s)return;o(!0);let e=u(),n=d.current;try{i(r=>{let a=r[r.length-1];if(!a)return r;let s=encodeURIComponent(a.timestamp);return fetch(`${e}/activity?limit=${t}&before=${s}`).then(e=>e.json()).then(e=>{if(d.current!==n){o(!1);return}i(t=>g([...t,...e])),c(e.length===t),o(!1)}).catch(()=>o(!1)),r})}catch{o(!1)}},[a,s,t]),_=(0,h.useCallback)(e=>{let t=e,n=d.current;if(!(!t||typeof t.type!=`string`)&&!(t.projectId&&t.projectId!==n)){if(t.type===`queue`&&Array.isArray(t.jobs)){let e=[];for(let n of t.jobs){let t=n.status===`completed`?`job_completed`:n.status===`failed`?`job_failed`:n.status===`canceled`?`job_canceled`:`job_started`;e.push({id:`${t}:${n.id}`,type:t,jobId:n.id,jobCommand:n.command??``,timestamp:n.startedAt??new Date().toISOString(),summary:`${t.replace(`_`,` `)}: ${n.command??``}`,costUsd:null})}e.length>0&&i(t=>g([...e,...t]))}if(t.type===`phase`&&t.phase&&t.state&&t.timestamp){let e={id:`phase:${t.phase}:${t.state}:${t.timestamp}`,type:`job_started`,jobId:`phase-${t.phase}`,jobCommand:r.t(`activity:feed.phaseCommand`,{phase:t.phase,state:t.state}),timestamp:t.timestamp,summary:`Phase ${t.phase} is ${t.state}`,costUsd:null};i(t=>g([e,...t]))}}},[]);return(0,h.useLayoutEffect)(()=>(f(`activity`,_),()=>p(`activity`)),[_,f,p]),{items:n,loading:a,hasMore:s,loadMore:m}}var v=n();function y(e,t){let n=Date.now()-new Date(e).getTime(),r=Math.floor(n/1e3);if(r<60)return t(`feed.relativeTime.seconds`,{value:r});let i=Math.floor(r/60);if(i<60)return t(`feed.relativeTime.minutes`,{value:i});let a=Math.floor(i/60);return a<24?t(`feed.relativeTime.hours`,{value:a}):t(`feed.relativeTime.days`,{value:Math.floor(a/24)})}function b({type:e}){switch(e){case`job_completed`:return(0,v.jsx)(f,{className:`w-4 h-4 text-green-500 aurora-light:text-accent-success flex-shrink-0`});case`job_failed`:return(0,v.jsx)(o,{className:`w-4 h-4 text-red-500 aurora-light:text-destructive flex-shrink-0`});case`job_canceled`:return(0,v.jsx)(m,{className:`w-4 h-4 text-muted-foreground flex-shrink-0`});default:return(0,v.jsx)(c,{className:`w-4 h-4 text-blue-500 aurora-light:text-accent-info flex-shrink-0`})}}function x(e,t){switch(e){case`job_completed`:return t(`common:status.completed`);case`job_failed`:return t(`common:status.failed`);case`job_canceled`:return t(`common:status.canceled`);default:return t(`feed.typeStarted`)}}function S(e){switch(e){case`job_completed`:return`text-green-500 aurora-light:text-accent-success`;case`job_failed`:return`text-red-500 aurora-light:text-destructive`;case`job_canceled`:return`text-muted-foreground`;default:return`text-blue-500 aurora-light:text-accent-info`}}function C(){let{t:e}=i(`activity`),{activeProjectId:t}=d(),{items:n,loading:r,hasMore:a,loadMore:o}=_({activeProjectId:t}),c=(0,h.useRef)(null);return(0,h.useEffect)(()=>{let e=c.current;if(!e)return;let t=new IntersectionObserver(e=>{e[0].isIntersecting&&a&&!r&&o()},{threshold:.1});return t.observe(e),()=>t.disconnect()},[a,r,o]),(0,v.jsxs)(`div`,{className:`flex flex-col h-full overflow-hidden`,children:[(0,v.jsxs)(`div`,{className:`flex items-center gap-2 px-4 py-3 border-b border-border bg-background/50`,children:[(0,v.jsx)(p,{className:`w-4 h-4 text-muted-foreground`}),(0,v.jsx)(`h1`,{className:`text-sm font-medium`,children:e(`feed.title`)})]}),(0,v.jsxs)(`div`,{className:`flex-1 overflow-y-auto`,children:[r&&n.length===0?(0,v.jsx)(`div`,{className:`flex items-center justify-center h-32`,children:(0,v.jsx)(s,{className:`w-4 h-4 animate-spin text-muted-foreground`})}):n.length===0?(0,v.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground`,children:[(0,v.jsx)(p,{className:`w-8 h-8 opacity-40`}),(0,v.jsx)(`p`,{className:`text-sm`,children:e(`feed.emptyTitle`)}),(0,v.jsx)(`p`,{className:`text-xs opacity-70`,children:e(`feed.emptyHint`)})]}):(0,v.jsx)(`ul`,{className:`divide-y divide-border/50`,children:n.map(t=>(0,v.jsxs)(`li`,{className:`flex items-center gap-3 px-4 py-2.5 hover:bg-accent/30 transition-colors`,children:[(0,v.jsx)(b,{type:t.type}),(0,v.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,v.jsx)(`p`,{className:`text-xs text-foreground truncate`,title:t.jobCommand,children:t.jobCommand}),(0,v.jsxs)(`div`,{className:`flex items-center gap-2 mt-0.5`,children:[(0,v.jsx)(`span`,{className:`text-xs font-medium ${S(t.type)}`,children:x(t.type,e)}),t.costUsd!=null&&(0,v.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`$`,t.costUsd.toFixed(4)]})]})]}),(0,v.jsx)(`span`,{className:`text-xs text-muted-foreground flex-shrink-0 tabular-nums`,children:y(t.timestamp,e)})]},`${t.type}:${t.jobId}`))}),(0,v.jsx)(`div`,{ref:c,className:`h-1`}),r&&n.length>0&&(0,v.jsx)(`div`,{className:`flex justify-center py-3`,children:(0,v.jsx)(s,{className:`w-4 h-4 animate-spin text-muted-foreground`})}),!a&&n.length>0&&(0,v.jsx)(`p`,{className:`text-center text-xs text-muted-foreground py-3`,children:e(`feed.allLoaded`)})]})]})}export{C as default};
|