forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
Binary file
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeEvidenceText } = require('../review-adapter');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PR-monitor Actions-summary renderer. It turns one read-only
|
|
7
|
+
* `gatherPrBundle` result (lib/pr-bundle.js) into deterministic Markdown for
|
|
8
|
+
* the workflow's `GITHUB_STEP_SUMMARY` surface.
|
|
9
|
+
*
|
|
10
|
+
* This is presentation only: it displays the canonical verdict supplied by
|
|
11
|
+
* `forge shepherd <pr> --pull --json`, lists unresolved review threads and CI
|
|
12
|
+
* state, and never merges or resolves anything. The `pr-verdict:*` label is a
|
|
13
|
+
* cheap visibility projection of that same verdict, not merge authority.
|
|
14
|
+
*
|
|
15
|
+
* @module pr-monitor/render-summary
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
|
|
20
|
+
* The verdict is computed once by pr-pull and passed in; this map only decides
|
|
21
|
+
* how it is displayed, so there is no second verdict ladder to drift.
|
|
22
|
+
*/
|
|
23
|
+
const VERDICT_HEADLINE = {
|
|
24
|
+
INCOMPLETE: '⚪ **Verdict: `INCOMPLETE`** — bounded review/check evidence is incomplete; merge readiness blocked (fail-closed).',
|
|
25
|
+
UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
|
|
26
|
+
'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
|
|
27
|
+
BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
|
|
28
|
+
'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
|
|
29
|
+
'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
|
|
30
|
+
'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
|
|
31
|
+
'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const MAX_SUMMARY_THREADS = 64;
|
|
35
|
+
const MAX_SUMMARY_CHECKS = 20;
|
|
36
|
+
const MAX_SUMMARY_AUTHORS = 12;
|
|
37
|
+
const MAX_SUMMARY_TEXT_CHARS = 256;
|
|
38
|
+
const MAX_SUMMARY_SIGNALS = 16;
|
|
39
|
+
const MAX_SUMMARY_CHARS = 32768;
|
|
40
|
+
|
|
41
|
+
/** Render the one-line headline for a canonical verdict, failing closed. */
|
|
42
|
+
function verdictHeadline(verdict) {
|
|
43
|
+
return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render untrusted text as a Markdown code span without allowing its backticks
|
|
48
|
+
* or line breaks to change the surrounding summary structure.
|
|
49
|
+
*
|
|
50
|
+
* CommonMark permits a code span to use more than one backtick. Pick a fence
|
|
51
|
+
* longer than every run in the value, and flatten CR/LF so the summary stays
|
|
52
|
+
* one line per diagnostic.
|
|
53
|
+
*/
|
|
54
|
+
function mdCode(value) {
|
|
55
|
+
const text = normalizeEvidenceText(value, { maxChars: MAX_SUMMARY_TEXT_CHARS });
|
|
56
|
+
let longestRun = 0;
|
|
57
|
+
let currentRun = 0;
|
|
58
|
+
for (const character of text) {
|
|
59
|
+
if (character === '`') {
|
|
60
|
+
currentRun += 1;
|
|
61
|
+
longestRun = Math.max(longestRun, currentRun);
|
|
62
|
+
} else {
|
|
63
|
+
currentRun = 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
67
|
+
const content = text.startsWith('`') || text.endsWith('`') ? ` ${text} ` : text;
|
|
68
|
+
return `${fence}${content}${fence}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Cap threads listed per author so a noisy PR cannot produce an enormous summary. */
|
|
72
|
+
const MAX_THREADS_PER_AUTHOR = 8;
|
|
73
|
+
|
|
74
|
+
/** Group unresolved review-thread comments by author in deterministic order. */
|
|
75
|
+
function groupByAuthor(comments) {
|
|
76
|
+
const byAuthor = new Map();
|
|
77
|
+
for (const comment of (Array.isArray(comments) ? comments : [])) {
|
|
78
|
+
const author = normalizeEvidenceText(comment.author || 'unknown', { maxChars: MAX_SUMMARY_TEXT_CHARS });
|
|
79
|
+
if (!byAuthor.has(author)) byAuthor.set(author, []);
|
|
80
|
+
byAuthor.get(author).push(comment);
|
|
81
|
+
}
|
|
82
|
+
return [...byAuthor.entries()].sort((a, b) => {
|
|
83
|
+
if (b[1].length !== a[1].length) return b[1].length - a[1].length;
|
|
84
|
+
if (a[0] < b[0]) return -1;
|
|
85
|
+
if (a[0] > b[0]) return 1;
|
|
86
|
+
return 0;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One-line locator for a thread: `path:line` when known, else its id. */
|
|
91
|
+
function threadLocator(thread) {
|
|
92
|
+
if (thread.path) {
|
|
93
|
+
const path = normalizeEvidenceText(thread.path, { maxChars: MAX_SUMMARY_TEXT_CHARS });
|
|
94
|
+
return thread.line != null ? `${path}:${thread.line}` : path;
|
|
95
|
+
}
|
|
96
|
+
return normalizeEvidenceText(thread.threadId || '(thread)', { maxChars: MAX_SUMMARY_TEXT_CHARS });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validThread(thread) {
|
|
100
|
+
if (!thread || typeof thread !== 'object' || Array.isArray(thread)
|
|
101
|
+
|| typeof thread.author !== 'string' || !thread.author.trim()) return false;
|
|
102
|
+
const hasPath = typeof thread.path === 'string' && thread.path.trim();
|
|
103
|
+
const hasId = typeof thread.threadId === 'string' && thread.threadId.trim();
|
|
104
|
+
return Boolean(hasPath || hasId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validCheck(check) {
|
|
108
|
+
return Boolean(check && typeof check === 'object' && !Array.isArray(check)
|
|
109
|
+
&& typeof check.name === 'string' && check.name.trim());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Render unresolved review threads, preserving fail-closed availability. */
|
|
113
|
+
function renderThreads(bundle, lines) {
|
|
114
|
+
// Empty arrays are ambiguous when the adapter could not read comments. Only
|
|
115
|
+
// an explicit available:true read may report zero unresolved threads.
|
|
116
|
+
if (bundle.unresolvedCommentsAvailable !== true) {
|
|
117
|
+
const why = bundle.unresolvedCommentsError || 'thread read unavailable (capability absent)';
|
|
118
|
+
lines.push('### Review threads');
|
|
119
|
+
lines.push(`⚠️ Review threads were **unreadable** this pass (${mdCode(why)}) — not treated as zero. Re-run once the read recovers.`);
|
|
120
|
+
lines.push('');
|
|
121
|
+
return 'INCOMPLETE';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!Array.isArray(bundle.unresolvedComments)
|
|
125
|
+
|| bundle.unresolvedComments.slice(0, MAX_SUMMARY_THREADS + 1).some((thread) => !validThread(thread))) {
|
|
126
|
+
lines.push(
|
|
127
|
+
'### Review threads',
|
|
128
|
+
'⚠️ Review thread evidence was **malformed** this pass — not treated as zero.',
|
|
129
|
+
''
|
|
130
|
+
);
|
|
131
|
+
return 'INCOMPLETE';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const comments = bundle.unresolvedComments;
|
|
135
|
+
const incomplete = comments.length > MAX_SUMMARY_THREADS;
|
|
136
|
+
if (comments.length === 0) {
|
|
137
|
+
lines.push('### Review threads');
|
|
138
|
+
lines.push('✅ No unresolved review threads.');
|
|
139
|
+
lines.push('');
|
|
140
|
+
return 'ZERO';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const boundedComments = comments.slice(0, MAX_SUMMARY_THREADS);
|
|
144
|
+
const groups = groupByAuthor(boundedComments).slice(0, MAX_SUMMARY_AUTHORS);
|
|
145
|
+
lines.push(`### Unresolved review threads (${comments.length})`);
|
|
146
|
+
lines.push('');
|
|
147
|
+
for (const [author, threads] of groups) {
|
|
148
|
+
lines.push(`- **${mdCode(author)}** — ${threads.length}`);
|
|
149
|
+
for (const thread of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
|
|
150
|
+
lines.push(` - ${mdCode(threadLocator(thread))}`);
|
|
151
|
+
}
|
|
152
|
+
if (threads.length > MAX_THREADS_PER_AUTHOR) {
|
|
153
|
+
lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (comments.length > boundedComments.length) {
|
|
157
|
+
lines.push(`- …and ${comments.length - boundedComments.length} more thread(s) omitted by the bounded summary`);
|
|
158
|
+
}
|
|
159
|
+
const representedThreads = groups.reduce((total, [, threads]) => total + threads.length, 0);
|
|
160
|
+
if (representedThreads < boundedComments.length) {
|
|
161
|
+
lines.push(`- …and ${boundedComments.length - representedThreads} thread(s) from additional authors`);
|
|
162
|
+
}
|
|
163
|
+
lines.push('');
|
|
164
|
+
return incomplete ? 'INCOMPLETE' : 'OPEN';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Render failing and pending checks, preserving fail-closed availability. */
|
|
168
|
+
function renderChecks(bundle, lines) {
|
|
169
|
+
// Only ciAvailable:true permits a clean-check claim. Missing or false means
|
|
170
|
+
// the read did not complete, so empty arrays must not look green.
|
|
171
|
+
if (bundle.ciAvailable !== true) {
|
|
172
|
+
lines.push('### Checks');
|
|
173
|
+
lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
|
|
174
|
+
lines.push('');
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ci = bundle.ci || {};
|
|
179
|
+
const checkArrays = [ci.checks, ci.failing, ci.pending];
|
|
180
|
+
if (checkArrays.some((checks) => !Array.isArray(checks))
|
|
181
|
+
|| checkArrays.some((checks) => checks.slice(0, MAX_SUMMARY_CHECKS + 1)
|
|
182
|
+
.some((check) => !validCheck(check)))) {
|
|
183
|
+
lines.push(
|
|
184
|
+
'### Checks',
|
|
185
|
+
'⚠️ Check evidence was **malformed** this pass — not treated as green.',
|
|
186
|
+
''
|
|
187
|
+
);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const failing = ci.failing;
|
|
191
|
+
const pending = ci.pending;
|
|
192
|
+
const incomplete = checkArrays.some((checks) => checks.length > MAX_SUMMARY_CHECKS);
|
|
193
|
+
lines.push('### Checks');
|
|
194
|
+
if (failing.length === 0 && pending.length === 0) {
|
|
195
|
+
lines.push('✅ No failing or pending checks.');
|
|
196
|
+
} else {
|
|
197
|
+
if (failing.length > 0) {
|
|
198
|
+
lines.push(`- ❌ **Failing (${failing.length}):** ${failing.slice(0, MAX_SUMMARY_CHECKS).map((check) => mdCode(check.name || '?')).join(', ')}`);
|
|
199
|
+
if (failing.length > MAX_SUMMARY_CHECKS) {
|
|
200
|
+
lines.push(` - …and ${failing.length - MAX_SUMMARY_CHECKS} more failing check(s)`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (pending.length > 0) {
|
|
204
|
+
lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.slice(0, MAX_SUMMARY_CHECKS).map((check) => mdCode(check.name || '?')).join(', ')}`);
|
|
205
|
+
if (pending.length > MAX_SUMMARY_CHECKS) {
|
|
206
|
+
lines.push(` - …and ${pending.length - MAX_SUMMARY_CHECKS} more pending check(s)`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
lines.push('');
|
|
211
|
+
return !incomplete;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Render the PR monitor's Actions job summary.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js)
|
|
218
|
+
* @param {object} [opts]
|
|
219
|
+
* @param {Date} [opts.now] - injected clock for deterministic output
|
|
220
|
+
* @param {string} [opts.verdict] - canonical `--pull` verdict
|
|
221
|
+
* @param {string[]} [opts.unreadable] - unreadable signal names from `--pull`
|
|
222
|
+
* @param {string|number} [opts.pr] - PR number for the CLI diagnostics hint
|
|
223
|
+
* @returns {{ body: string }}
|
|
224
|
+
*/
|
|
225
|
+
function renderSummary(bundle = {}, opts = {}) {
|
|
226
|
+
const now = opts.now instanceof Date ? opts.now : new Date();
|
|
227
|
+
const lines = ['## 🔭 Forge PR Monitor', ''];
|
|
228
|
+
const verdict = String(opts.verdict || '').toUpperCase();
|
|
229
|
+
const isUnknown = verdict === 'UNKNOWN' || !VERDICT_HEADLINE[verdict];
|
|
230
|
+
const unreadable = Array.isArray(opts.unreadable)
|
|
231
|
+
? opts.unreadable.filter(Boolean).slice(0, MAX_SUMMARY_SIGNALS)
|
|
232
|
+
: [];
|
|
233
|
+
|
|
234
|
+
const headlineIndex = lines.length;
|
|
235
|
+
lines.push(verdictHeadline(opts.verdict));
|
|
236
|
+
if (isUnknown && unreadable.length > 0) {
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push(`> Unreadable signal(s): ${unreadable.map((signal) => mdCode(signal)).join(', ')}.`);
|
|
239
|
+
}
|
|
240
|
+
lines.push('');
|
|
241
|
+
lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and never resolves review threads — a human merges in the GitHub UI._');
|
|
242
|
+
lines.push('');
|
|
243
|
+
|
|
244
|
+
let threadState = renderThreads(bundle, lines);
|
|
245
|
+
const checksComplete = renderChecks(bundle, lines);
|
|
246
|
+
|
|
247
|
+
const branch = bundle.branch || {};
|
|
248
|
+
if ((branch.behind || 0) > 0) {
|
|
249
|
+
lines.push(`> Branch is **${branch.behind}** commit(s) behind base.`);
|
|
250
|
+
lines.push('');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const pr = opts.pr || bundle.pr || '<pr>';
|
|
254
|
+
const detailCommand = mdCode(`forge shepherd ${pr} --pull --json`);
|
|
255
|
+
lines.push(
|
|
256
|
+
'---',
|
|
257
|
+
`Detailed JSON: ${detailCommand}`,
|
|
258
|
+
`_Updated ${now.toISOString()} · summary-only monitor · labels state, never merges, never resolves threads._`
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
let evidenceStatus = threadState === 'INCOMPLETE' || !checksComplete ? 'INCOMPLETE' : 'COMPLETE';
|
|
262
|
+
if (evidenceStatus === 'INCOMPLETE') lines[headlineIndex] = verdictHeadline('INCOMPLETE');
|
|
263
|
+
let body = lines.join('\n');
|
|
264
|
+
if (body.length > MAX_SUMMARY_CHARS) {
|
|
265
|
+
threadState = 'INCOMPLETE';
|
|
266
|
+
evidenceStatus = 'INCOMPLETE';
|
|
267
|
+
body = [
|
|
268
|
+
'## 🔭 Forge PR Monitor',
|
|
269
|
+
'',
|
|
270
|
+
verdictHeadline('INCOMPLETE'),
|
|
271
|
+
'',
|
|
272
|
+
'⚠️ Summary evidence exceeded the bounded rendering limit; inspect the detailed JSON.',
|
|
273
|
+
'',
|
|
274
|
+
'_This monitor does not merge and never resolves review threads._',
|
|
275
|
+
'',
|
|
276
|
+
`Detailed JSON: ${detailCommand}`,
|
|
277
|
+
`_Updated ${now.toISOString()} · summary-only monitor · labels state, never merges, never resolves threads._`,
|
|
278
|
+
].join('\n');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { body, evidenceStatus, threadState };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
MAX_SUMMARY_CHARS,
|
|
286
|
+
MAX_SUMMARY_CHECKS,
|
|
287
|
+
MAX_SUMMARY_THREADS,
|
|
288
|
+
renderSummary,
|
|
289
|
+
verdictHeadline,
|
|
290
|
+
groupByAuthor,
|
|
291
|
+
threadLocator,
|
|
292
|
+
MAX_THREADS_PER_AUTHOR,
|
|
293
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const preflightCommand = require('../commands/preflight');
|
|
6
|
+
|
|
7
|
+
const MAX_FINDINGS = 20;
|
|
8
|
+
const MAX_FINDING_CHARS = 500;
|
|
9
|
+
|
|
10
|
+
function boundedLines(value) {
|
|
11
|
+
return String(value || '')
|
|
12
|
+
.split(/\r?\n/)
|
|
13
|
+
.map(line => line.trim())
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.slice(0, MAX_FINDINGS)
|
|
16
|
+
.map(line => line.slice(0, MAX_FINDING_CHARS));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function defaultProbeCodeRabbit({ projectRoot }, exec = execFileSync) {
|
|
20
|
+
try {
|
|
21
|
+
exec('coderabbit', ['--version'], {
|
|
22
|
+
cwd: projectRoot,
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
timeout: 10_000,
|
|
25
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
});
|
|
28
|
+
return { available: true };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const reason = error?.code === 'ENOENT' ? 'CLI not installed' : 'CLI unavailable or unauthenticated';
|
|
31
|
+
return { available: false, reason };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseCodeRabbitEvents(output) {
|
|
36
|
+
const rawLines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
37
|
+
const events = [];
|
|
38
|
+
let malformed = rawLines.length === 0;
|
|
39
|
+
for (const line of rawLines) {
|
|
40
|
+
try {
|
|
41
|
+
const event = JSON.parse(line);
|
|
42
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) malformed = true;
|
|
43
|
+
else events.push(event);
|
|
44
|
+
} catch {
|
|
45
|
+
malformed = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { events, malformed };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function defaultRunCodeRabbit({ projectRoot, base, baseCommit }, exec = execFileSync) {
|
|
52
|
+
try {
|
|
53
|
+
const output = exec('coderabbit', ['review', '--agent', '--base', baseCommit || base], {
|
|
54
|
+
cwd: projectRoot,
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
timeout: 180_000,
|
|
57
|
+
maxBuffer: 1024 * 1024,
|
|
58
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
});
|
|
61
|
+
const { events, malformed } = parseCodeRabbitEvents(output);
|
|
62
|
+
const terminal = events.at(-1);
|
|
63
|
+
const terminalValid = terminal?.type === 'complete'
|
|
64
|
+
&& ['review_completed', 'review_skipped'].includes(terminal.status)
|
|
65
|
+
&& Number.isSafeInteger(terminal.findings) && terminal.findings >= 0;
|
|
66
|
+
const findingEvents = events.filter(event => event.type === 'finding');
|
|
67
|
+
const errorEvents = events.filter(event => event.type === 'error');
|
|
68
|
+
const clean = !malformed && terminalValid && terminal.findings === 0
|
|
69
|
+
&& findingEvents.length === 0 && errorEvents.length === 0;
|
|
70
|
+
const findings = findingEvents.slice(0, MAX_FINDINGS).map(event => String(
|
|
71
|
+
event.codegenInstructions || event.message || 'CodeRabbit finding',
|
|
72
|
+
).slice(0, MAX_FINDING_CHARS));
|
|
73
|
+
if (!clean && findings.length === 0) {
|
|
74
|
+
let detail = `CodeRabbit reported ${terminal?.findings || errorEvents.length} finding(s)`;
|
|
75
|
+
if (malformed || !terminalValid) {
|
|
76
|
+
detail = 'CodeRabbit agent output was malformed or incomplete';
|
|
77
|
+
}
|
|
78
|
+
findings.push(detail);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
ok: clean,
|
|
82
|
+
summary: clean ? 'local review completed with no findings' : `${findings.length} local review finding(s)`,
|
|
83
|
+
findings: clean ? [] : findings,
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const output = `${error?.stdout || ''}\n${error?.stderr || ''}`;
|
|
87
|
+
const findings = boundedLines(output);
|
|
88
|
+
if (/unauthori[sz]ed|authentication|not authenticated|log in|login required/i.test(output)) {
|
|
89
|
+
return { ok: false, unavailable: true, summary: 'CLI authentication unavailable', findings: [] };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
summary: findings[0] || 'local review failed',
|
|
94
|
+
findings,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function defaultRunDeterministic({ projectRoot, base, baseCommit, baseRef: resolvedBaseRef }, exec = execFileSync) {
|
|
100
|
+
const baseRef = baseCommit || resolvedBaseRef || (String(base || '').startsWith('origin/')
|
|
101
|
+
? String(base)
|
|
102
|
+
: `origin/${String(base || '')}`);
|
|
103
|
+
return preflightCommand.handler([], {}, projectRoot, {
|
|
104
|
+
log: () => {},
|
|
105
|
+
resolveChangeSet: ({ runAll }) => preflightCommand.resolveChangeSet(exec, { runAll, baseRef }),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function defaultResolveBaseCommit({ projectRoot, base, baseRef }, exec = execFileSync) {
|
|
110
|
+
const ref = baseRef || (String(base || '').startsWith('origin/')
|
|
111
|
+
? String(base)
|
|
112
|
+
: `origin/${String(base || '')}`);
|
|
113
|
+
const resolved = String(exec('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
|
114
|
+
cwd: projectRoot,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
timeout: 10_000,
|
|
117
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
118
|
+
windowsHide: true,
|
|
119
|
+
}) || '').trim().toLowerCase();
|
|
120
|
+
if (!/^[0-9a-f]{40}$/.test(resolved)) throw new Error(`PR base commit is unavailable for ${ref}`);
|
|
121
|
+
return resolved;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function providerResult(status, ok, summary, findings = []) {
|
|
125
|
+
return { status, ok, summary, findings: findings.slice(0, MAX_FINDINGS) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function deterministicFindings(results) {
|
|
129
|
+
return (Array.isArray(results) ? results : [])
|
|
130
|
+
.filter(result => result?.ok === false && !result.skipped)
|
|
131
|
+
.slice(0, MAX_FINDINGS)
|
|
132
|
+
.map(result => ({
|
|
133
|
+
provider: String(result.name || 'deterministic-gate').slice(0, 80),
|
|
134
|
+
detail: String(result.summary || 'gate failed').slice(0, MAX_FINDING_CHARS),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function notApplicableResult(context) {
|
|
139
|
+
const expectedHead = typeof context.expectedHead === 'string'
|
|
140
|
+
&& /^[0-9a-f]{40}$/i.test(context.expectedHead) ? context.expectedHead.toLowerCase() : null;
|
|
141
|
+
const localHead = typeof context.localHead === 'string' ? context.localHead.toLowerCase() : null;
|
|
142
|
+
const exactHeadAvailable = expectedHead !== null;
|
|
143
|
+
const exactHeadCheckedOut = exactHeadAvailable && localHead === expectedHead;
|
|
144
|
+
if (exactHeadCheckedOut && context.cleanTree === true) return null;
|
|
145
|
+
const dirtyCheckout = exactHeadCheckedOut && context.cleanTree !== true;
|
|
146
|
+
const summary = dirtyCheckout
|
|
147
|
+
? 'local checkout has uncommitted changes'
|
|
148
|
+
: (exactHeadAvailable ? 'local checkout is not the PR head' : 'PR head is unavailable');
|
|
149
|
+
return {
|
|
150
|
+
status: 'INCOMPLETE',
|
|
151
|
+
blocking: true,
|
|
152
|
+
providers: {
|
|
153
|
+
coderabbit: providerResult(
|
|
154
|
+
'NOT_APPLICABLE',
|
|
155
|
+
false,
|
|
156
|
+
summary,
|
|
157
|
+
),
|
|
158
|
+
deterministic: { status: 'NOT_APPLICABLE', ok: false, results: [] },
|
|
159
|
+
},
|
|
160
|
+
findings: [{ provider: 'local-preflight', detail: summary }],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function codeRabbitResult(context, probeCodeRabbit, runCodeRabbit) {
|
|
165
|
+
const probe = await probeCodeRabbit(context);
|
|
166
|
+
if (probe?.available !== true) {
|
|
167
|
+
return providerResult('UNAVAILABLE', false, probe?.reason || 'CLI unavailable');
|
|
168
|
+
}
|
|
169
|
+
const review = await runCodeRabbit(context);
|
|
170
|
+
if (review?.unavailable === true) {
|
|
171
|
+
return providerResult('UNAVAILABLE', false, review.summary || 'CLI unavailable');
|
|
172
|
+
}
|
|
173
|
+
return providerResult(
|
|
174
|
+
review?.ok === true ? 'PASS' : 'FAIL',
|
|
175
|
+
review?.ok === true,
|
|
176
|
+
review?.summary || 'local review completed',
|
|
177
|
+
Array.isArray(review?.findings) ? review.findings : [],
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function deterministicResult(context, runDeterministic) {
|
|
182
|
+
try {
|
|
183
|
+
return await runDeterministic(context);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { success: false, results: [], reason: error?.message || String(error) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function runLocalReviewPreflight(context, deps = {}) {
|
|
190
|
+
const probeCodeRabbit = deps.probeCodeRabbit || defaultProbeCodeRabbit;
|
|
191
|
+
const runCodeRabbit = deps.runCodeRabbit || defaultRunCodeRabbit;
|
|
192
|
+
const runDeterministic = deps.runDeterministic || defaultRunDeterministic;
|
|
193
|
+
|
|
194
|
+
const notApplicable = notApplicableResult(context);
|
|
195
|
+
if (notApplicable) return notApplicable;
|
|
196
|
+
|
|
197
|
+
let reviewContext = context;
|
|
198
|
+
const suppliedBaseCommit = typeof context.baseCommit === 'string'
|
|
199
|
+
&& /^[0-9a-f]{40}$/i.test(context.baseCommit);
|
|
200
|
+
const shouldResolveBase = deps.resolveBaseCommit !== false && !suppliedBaseCommit;
|
|
201
|
+
if (shouldResolveBase) {
|
|
202
|
+
const resolveBaseCommit = typeof deps.resolveBaseCommit === 'function'
|
|
203
|
+
? deps.resolveBaseCommit
|
|
204
|
+
: defaultResolveBaseCommit;
|
|
205
|
+
try {
|
|
206
|
+
const baseCommit = String(await resolveBaseCommit(context) || '').trim().toLowerCase();
|
|
207
|
+
if (!/^[0-9a-f]{40}$/.test(baseCommit)) throw new Error('PR base commit resolver returned an invalid commit');
|
|
208
|
+
reviewContext = { ...context, baseCommit };
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const detail = error?.message || String(error);
|
|
211
|
+
return {
|
|
212
|
+
status: 'INCOMPLETE',
|
|
213
|
+
blocking: true,
|
|
214
|
+
providers: {
|
|
215
|
+
coderabbit: providerResult('NOT_APPLICABLE', false, detail),
|
|
216
|
+
deterministic: { status: 'NOT_APPLICABLE', ok: false, results: [] },
|
|
217
|
+
},
|
|
218
|
+
findings: [{ provider: 'local-preflight', detail }],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
} else if (suppliedBaseCommit) {
|
|
222
|
+
reviewContext = { ...context, baseCommit: context.baseCommit.toLowerCase() };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const coderabbit = await codeRabbitResult(reviewContext, probeCodeRabbit, runCodeRabbit);
|
|
226
|
+
const deterministic = await deterministicResult(reviewContext, runDeterministic);
|
|
227
|
+
const gateResults = Array.isArray(deterministic?.results) ? deterministic.results : [];
|
|
228
|
+
const deterministicOk = deterministic?.success === true;
|
|
229
|
+
const findings = [
|
|
230
|
+
...coderabbit.findings.map(detail => ({ provider: 'coderabbit', detail })),
|
|
231
|
+
...deterministicFindings(gateResults),
|
|
232
|
+
].slice(0, MAX_FINDINGS);
|
|
233
|
+
if (!deterministicOk && findings.length === 0) {
|
|
234
|
+
findings.push({
|
|
235
|
+
provider: 'deterministic-gates',
|
|
236
|
+
detail: String(deterministic?.reason || 'preflight failed').slice(0, MAX_FINDING_CHARS),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const failed = !deterministicOk || coderabbit.status === 'FAIL';
|
|
241
|
+
const incomplete = coderabbit.status === 'UNAVAILABLE';
|
|
242
|
+
const blocking = failed || incomplete;
|
|
243
|
+
let status = 'PASS';
|
|
244
|
+
if (failed) status = 'FAIL';
|
|
245
|
+
else if (incomplete) status = 'INCOMPLETE';
|
|
246
|
+
return {
|
|
247
|
+
status,
|
|
248
|
+
blocking,
|
|
249
|
+
providers: {
|
|
250
|
+
coderabbit,
|
|
251
|
+
deterministic: {
|
|
252
|
+
status: deterministicOk ? 'PASS' : 'FAIL',
|
|
253
|
+
ok: deterministicOk,
|
|
254
|
+
results: gateResults,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
findings,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
MAX_FINDINGS,
|
|
263
|
+
boundedLines,
|
|
264
|
+
defaultProbeCodeRabbit,
|
|
265
|
+
defaultResolveBaseCommit,
|
|
266
|
+
defaultRunCodeRabbit,
|
|
267
|
+
defaultRunDeterministic,
|
|
268
|
+
runLocalReviewPreflight,
|
|
269
|
+
};
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
* one lock. This module is the lease PRIMITIVE only: pure fs + I/O, no spawned
|
|
17
17
|
* process and no reconcile loop (the daemon wires those up later).
|
|
18
18
|
*
|
|
19
|
-
* Payload JSON: `{ pid, token, startedAt, heartbeatAt
|
|
19
|
+
* Payload JSON: `{ pid, token, startedAt, heartbeatAt }`.
|
|
20
|
+
* Per-PR watcher authority lives only in Kernel owner rows.
|
|
20
21
|
*
|
|
21
22
|
* ## Ownership is by TOKEN, not pid
|
|
22
23
|
* Each successful `acquire` mints a unique `token`. Every mutating op
|
|
23
|
-
* (`stamp`/`
|
|
24
|
+
* (`stamp`/`release`) verifies that token against the on-disk
|
|
24
25
|
* lock before writing. A pid can be reused after a crash/reboot, and a wedged
|
|
25
26
|
* owner can revive after its lease was reclaimed — in both cases the token no
|
|
26
27
|
* longer matches, so the superseded holder can never resurrect or mutate a lease
|
|
@@ -37,7 +38,9 @@ const crypto = require('node:crypto');
|
|
|
37
38
|
const { resolveGitCommonDir } = require('../kernel/broker');
|
|
38
39
|
|
|
39
40
|
/** A wedged owner whose heartbeat is older than this (ms) is reclaimable. */
|
|
40
|
-
|
|
41
|
+
// Must exceed the longest synchronous daemon read (`gh pr list`, 30s) with
|
|
42
|
+
// enough margin that a blocked event loop cannot be reclaimed mid-read.
|
|
43
|
+
const STALE_MS = 90000;
|
|
41
44
|
const LOCK_FILE_MODE = 0o600;
|
|
42
45
|
|
|
43
46
|
/**
|
|
@@ -126,6 +129,9 @@ function tryExclusiveCreate(file, payload) {
|
|
|
126
129
|
* -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
|
|
127
130
|
* -> { ok:false, held } a LIVE, FRESH foreign owner holds it
|
|
128
131
|
* -> { ok:false, held } we lost the atomic takeover race
|
|
132
|
+
* -> { ok:false, held:null, legacyMigrationPending:true, reason:'legacy-lease-unreadable' }
|
|
133
|
+
* the lock exists but cannot be parsed —
|
|
134
|
+
* never reclaimed, never deleted here
|
|
129
135
|
*
|
|
130
136
|
* `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
|
|
131
137
|
* a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
|
|
@@ -139,11 +145,12 @@ function acquire(projectRoot, {
|
|
|
139
145
|
now = () => Date.now(),
|
|
140
146
|
token = crypto.randomUUID(),
|
|
141
147
|
onBeforeTakeover = null,
|
|
148
|
+
preserveLegacy = false,
|
|
142
149
|
} = {}) {
|
|
143
150
|
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
144
151
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
145
152
|
const iso = new Date(now()).toISOString();
|
|
146
|
-
const payload = { pid, token, startedAt: iso, heartbeatAt: iso
|
|
153
|
+
const payload = { pid, token, startedAt: iso, heartbeatAt: iso };
|
|
147
154
|
|
|
148
155
|
// Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
|
|
149
156
|
if (tryExclusiveCreate(file, payload)) {
|
|
@@ -156,15 +163,33 @@ function acquire(projectRoot, {
|
|
|
156
163
|
return { ok: false, held };
|
|
157
164
|
}
|
|
158
165
|
|
|
166
|
+
// NO caller may discard an unreadable lease: its bytes may be the only
|
|
167
|
+
// evidence of legacy watchers, and the daemon acquires HERE but migrates
|
|
168
|
+
// LATER — so an opt-in guard would still delete that evidence before
|
|
169
|
+
// migration could read it. This is unconditional at the shared acquisition
|
|
170
|
+
// boundary: fail closed, leave the bytes exactly as found, and surface a
|
|
171
|
+
// reason a caller can act on. Recovery belongs to migration, never to
|
|
172
|
+
// acquisition.
|
|
173
|
+
if (fs.existsSync(file) && !held) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false, held: null, legacyMigrationPending: true, reason: 'legacy-lease-unreadable',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (preserveLegacy && Array.isArray(held?.watchers)) {
|
|
180
|
+
return { ok: false, held, legacyMigrationPending: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
159
183
|
// Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
|
|
160
184
|
// remove the stale lock, then re-create it with O_EXCL. If a competitor
|
|
161
185
|
// recreated it first, our exclusive create fails (EEXIST) and we back off — so
|
|
162
186
|
// two racers reclaiming the same stale lock can NEVER both win. A wedged owner
|
|
163
187
|
// that revives after we delete its lock is stopped by the per-lease `token`
|
|
164
|
-
// guard on stamp()/
|
|
188
|
+
// guard on stamp()/release(), never able to resurrect it here.
|
|
165
189
|
fs.rmSync(file, { force: true });
|
|
166
190
|
if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
|
|
167
|
-
|
|
191
|
+
const takeoverPayload = Array.isArray(held?.watchers) ? { ...payload, watchers: held.watchers } : payload;
|
|
192
|
+
if (!tryExclusiveCreate(file, takeoverPayload)) {
|
|
168
193
|
return { ok: false, held: readLock(file) };
|
|
169
194
|
}
|
|
170
195
|
return { ok: true, file, token, reclaimed: true };
|
|
@@ -201,19 +226,6 @@ function stopHeartbeat(timer) {
|
|
|
201
226
|
if (timer) clearInterval(timer);
|
|
202
227
|
}
|
|
203
228
|
|
|
204
|
-
/**
|
|
205
|
-
* Rewrite the `watchers[]` array on OUR lock. Returns false when the lock is
|
|
206
|
-
* missing or NOT ours by `token`.
|
|
207
|
-
*/
|
|
208
|
-
function updateWatchers(projectRoot, prNumbers, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
209
|
-
const file = lockFilePath(projectRoot, { gitCommonDir });
|
|
210
|
-
const held = readLock(file);
|
|
211
|
-
if (!held || !ownsLock(held, { token, pid })) return false;
|
|
212
|
-
held.watchers = Array.isArray(prNumbers) ? prNumbers : [];
|
|
213
|
-
writeLock(file, held);
|
|
214
|
-
return true;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
229
|
/**
|
|
218
230
|
* Release the lease — delete the lock ONLY when it is ours by `token`, so a
|
|
219
231
|
* foreign or already-reclaimed lock is never removed out from under its owner
|
|
@@ -229,6 +241,12 @@ function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
|
229
241
|
}
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
/** True only while this exact pid+token still owns the shared lease. */
|
|
245
|
+
function owns(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
|
|
246
|
+
const held = readLock(lockFilePath(projectRoot, { gitCommonDir }));
|
|
247
|
+
return Boolean(held && ownsLock(held, { token, pid }));
|
|
248
|
+
}
|
|
249
|
+
|
|
232
250
|
module.exports = {
|
|
233
251
|
STALE_MS,
|
|
234
252
|
lockFilePath,
|
|
@@ -238,6 +256,6 @@ module.exports = {
|
|
|
238
256
|
stamp,
|
|
239
257
|
startHeartbeat,
|
|
240
258
|
stopHeartbeat,
|
|
241
|
-
updateWatchers,
|
|
242
259
|
release,
|
|
260
|
+
owns,
|
|
243
261
|
};
|