@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/review-agent.mjs
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-agent.mjs — Automated Code Review for Vibe-Kanban Tasks
|
|
3
|
+
*
|
|
4
|
+
* Reviews PRs when tasks move to "inreview" status.
|
|
5
|
+
* Only flags CRITICAL issues: security, bugs, broken functionality,
|
|
6
|
+
* missing implementations. Ignores style, naming, minor quality concerns.
|
|
7
|
+
*
|
|
8
|
+
* @module review-agent
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { execWithRetry, getPoolSdkName } from "./agent-pool.mjs";
|
|
13
|
+
import { loadConfig } from "./config.mjs";
|
|
14
|
+
import { resolvePromptTemplate } from "./agent-prompts.mjs";
|
|
15
|
+
|
|
16
|
+
const TAG = "[review-agent]";
|
|
17
|
+
|
|
18
|
+
/** Maximum diff size before truncation (characters). */
|
|
19
|
+
const MAX_DIFF_CHARS = 50_000;
|
|
20
|
+
|
|
21
|
+
/** Default review timeout: 5 minutes. */
|
|
22
|
+
const DEFAULT_REVIEW_TIMEOUT_MS = 5 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** Default max concurrent reviews. */
|
|
25
|
+
const DEFAULT_MAX_CONCURRENT = 2;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Review Prompt
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the structured review prompt.
|
|
33
|
+
* @param {string} diff - PR diff content
|
|
34
|
+
* @param {string} taskDescription - Task description for context
|
|
35
|
+
* @param {string} [template]
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function buildReviewPrompt(diff, taskDescription, template) {
|
|
39
|
+
const fallback = `You are a senior code reviewer for this software project.
|
|
40
|
+
|
|
41
|
+
Review the following PR diff for CRITICAL issues ONLY:
|
|
42
|
+
|
|
43
|
+
## What to flag (ONLY these categories):
|
|
44
|
+
1. **Security vulnerabilities** - injection, auth bypass, key exposure, unsafe crypto
|
|
45
|
+
2. **Bugs** - logic errors, nil pointer dereferences, race conditions, data corruption
|
|
46
|
+
3. **Missing implementations** - placeholder/stub code, TODO comments left in, empty function bodies
|
|
47
|
+
4. **Broken functionality** - code that won't compile, tests that fail, broken imports
|
|
48
|
+
|
|
49
|
+
## What to IGNORE (do NOT flag):
|
|
50
|
+
- Code style or formatting
|
|
51
|
+
- Variable naming conventions
|
|
52
|
+
- Minor code quality improvements
|
|
53
|
+
- Missing comments or documentation
|
|
54
|
+
- Performance optimizations (unless critical)
|
|
55
|
+
- Test coverage gaps (unless zero tests for critical code)
|
|
56
|
+
|
|
57
|
+
## PR Diff:
|
|
58
|
+
\`\`\`diff
|
|
59
|
+
${diff}
|
|
60
|
+
\`\`\`
|
|
61
|
+
|
|
62
|
+
## Task Description:
|
|
63
|
+
${taskDescription || "(no description provided)"}
|
|
64
|
+
|
|
65
|
+
## Response Format:
|
|
66
|
+
Respond with ONLY a JSON object (no markdown, no explanation):
|
|
67
|
+
{
|
|
68
|
+
"verdict": "approved" | "changes_requested",
|
|
69
|
+
"issues": [
|
|
70
|
+
{
|
|
71
|
+
"severity": "critical" | "major",
|
|
72
|
+
"category": "security" | "bug" | "missing_impl" | "broken",
|
|
73
|
+
"file": "path/to/file",
|
|
74
|
+
"line": 123,
|
|
75
|
+
"description": "What's wrong and why it matters"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"summary": "One sentence overall assessment"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
If no critical issues found, return:
|
|
82
|
+
{"verdict": "approved", "issues": [], "summary": "No critical issues found"}`;
|
|
83
|
+
return resolvePromptTemplate(
|
|
84
|
+
template,
|
|
85
|
+
{
|
|
86
|
+
DIFF: diff,
|
|
87
|
+
TASK_DESCRIPTION: taskDescription || "(no description provided)",
|
|
88
|
+
},
|
|
89
|
+
fallback,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Diff Retrieval
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract PR number from a PR URL.
|
|
99
|
+
* @param {string} prUrl
|
|
100
|
+
* @returns {number|null}
|
|
101
|
+
*/
|
|
102
|
+
function extractPrNumber(prUrl) {
|
|
103
|
+
if (!prUrl) return null;
|
|
104
|
+
const m = prUrl.match(/\/pull\/(\d+)/);
|
|
105
|
+
return m ? Number(m[1]) : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract repo slug (owner/repo) from a PR URL.
|
|
110
|
+
* @param {string} prUrl
|
|
111
|
+
* @returns {string|null}
|
|
112
|
+
*/
|
|
113
|
+
function extractRepoSlug(prUrl) {
|
|
114
|
+
if (!prUrl) return null;
|
|
115
|
+
const m = prUrl.match(/github\.com\/([^/]+\/[^/]+)\/pull\//);
|
|
116
|
+
return m ? m[1] : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get the PR diff using `gh pr diff` or `git diff`.
|
|
121
|
+
* @param {{ prUrl?: string, branchName?: string }} opts
|
|
122
|
+
* @returns {{ diff: string, truncated: boolean }}
|
|
123
|
+
*/
|
|
124
|
+
function getPrDiff({ prUrl, branchName }) {
|
|
125
|
+
let diff = "";
|
|
126
|
+
|
|
127
|
+
// Strategy 1: gh pr diff
|
|
128
|
+
const prNumber = extractPrNumber(prUrl);
|
|
129
|
+
const repoSlug = extractRepoSlug(prUrl);
|
|
130
|
+
if (prNumber && repoSlug) {
|
|
131
|
+
try {
|
|
132
|
+
const result = spawnSync(
|
|
133
|
+
"gh",
|
|
134
|
+
["pr", "diff", String(prNumber), "--repo", repoSlug],
|
|
135
|
+
{ encoding: "utf8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] },
|
|
136
|
+
);
|
|
137
|
+
if (result.status === 0 && result.stdout?.trim()) {
|
|
138
|
+
diff = result.stdout;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
/* fall through to git diff */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Strategy 2: git diff main...<branch>
|
|
146
|
+
if (!diff && branchName) {
|
|
147
|
+
try {
|
|
148
|
+
const result = spawnSync("git", ["diff", `main...${branchName}`], {
|
|
149
|
+
encoding: "utf8",
|
|
150
|
+
timeout: 30_000,
|
|
151
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
152
|
+
});
|
|
153
|
+
if (result.status === 0 && result.stdout?.trim()) {
|
|
154
|
+
diff = result.stdout;
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
/* ignore */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Truncate if needed
|
|
162
|
+
let truncated = false;
|
|
163
|
+
if (diff.length > MAX_DIFF_CHARS) {
|
|
164
|
+
diff = diff.slice(0, MAX_DIFF_CHARS);
|
|
165
|
+
truncated = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { diff, truncated };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Result Parsing
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse the review JSON from agent output.
|
|
177
|
+
* Handles markdown fences, surrounding text, and invalid JSON gracefully.
|
|
178
|
+
* @param {string} raw
|
|
179
|
+
* @returns {{ approved: boolean, issues: Array, summary: string }}
|
|
180
|
+
*/
|
|
181
|
+
function parseReviewResult(raw) {
|
|
182
|
+
if (!raw || !raw.trim()) {
|
|
183
|
+
return {
|
|
184
|
+
approved: true,
|
|
185
|
+
issues: [],
|
|
186
|
+
summary: "Empty agent output — auto-approved",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let text = raw.trim();
|
|
191
|
+
|
|
192
|
+
// Strip markdown code fences
|
|
193
|
+
const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
194
|
+
if (fenceMatch) {
|
|
195
|
+
text = fenceMatch[1].trim();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Try direct JSON parse
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(text);
|
|
201
|
+
return normalizeResult(parsed);
|
|
202
|
+
} catch {
|
|
203
|
+
/* continue to regex extraction */
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Extract first JSON object from text
|
|
207
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
208
|
+
if (jsonMatch) {
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
211
|
+
return normalizeResult(parsed);
|
|
212
|
+
} catch {
|
|
213
|
+
/* fall through */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Couldn't parse — auto-approve with note
|
|
218
|
+
return {
|
|
219
|
+
approved: true,
|
|
220
|
+
issues: [],
|
|
221
|
+
summary: "Could not parse review output — auto-approved",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Normalize a parsed review object.
|
|
227
|
+
* @param {Object} obj
|
|
228
|
+
* @returns {{ approved: boolean, issues: Array, summary: string }}
|
|
229
|
+
*/
|
|
230
|
+
function normalizeResult(obj) {
|
|
231
|
+
const approved = obj.verdict !== "changes_requested";
|
|
232
|
+
const issues = Array.isArray(obj.issues) ? obj.issues : [];
|
|
233
|
+
const summary = typeof obj.summary === "string" ? obj.summary : "";
|
|
234
|
+
return { approved, issues, summary };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// ReviewAgent Class
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
export class ReviewAgent {
|
|
242
|
+
/** @type {Map<string, Promise>} */
|
|
243
|
+
#activeReviews = new Map();
|
|
244
|
+
|
|
245
|
+
/** @type {Array<{ id: string, title: string, branchName: string, prUrl: string, description: string }>} */
|
|
246
|
+
#queue = [];
|
|
247
|
+
|
|
248
|
+
/** @type {Set<string>} - task IDs already reviewed or in-flight */
|
|
249
|
+
#seen = new Set();
|
|
250
|
+
|
|
251
|
+
#completedCount = 0;
|
|
252
|
+
#running = false;
|
|
253
|
+
#processing = false;
|
|
254
|
+
|
|
255
|
+
/** @type {string} */
|
|
256
|
+
#sdk;
|
|
257
|
+
|
|
258
|
+
/** @type {string|undefined} */
|
|
259
|
+
#model;
|
|
260
|
+
|
|
261
|
+
/** @type {number} */
|
|
262
|
+
#maxConcurrent;
|
|
263
|
+
|
|
264
|
+
/** @type {number} */
|
|
265
|
+
#reviewTimeoutMs;
|
|
266
|
+
|
|
267
|
+
/** @type {Function|undefined} */
|
|
268
|
+
#onReviewComplete;
|
|
269
|
+
|
|
270
|
+
/** @type {Function|undefined} */
|
|
271
|
+
#sendTelegram;
|
|
272
|
+
|
|
273
|
+
/** @type {string|undefined} */
|
|
274
|
+
#promptTemplate;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {Object} [options]
|
|
278
|
+
* @param {string} [options.sdk]
|
|
279
|
+
* @param {string} [options.model]
|
|
280
|
+
* @param {number} [options.maxConcurrentReviews]
|
|
281
|
+
* @param {number} [options.reviewTimeoutMs]
|
|
282
|
+
* @param {Function} [options.onReviewComplete]
|
|
283
|
+
* @param {Function} [options.sendTelegram]
|
|
284
|
+
* @param {string} [options.promptTemplate]
|
|
285
|
+
*/
|
|
286
|
+
constructor(options = {}) {
|
|
287
|
+
this.#sdk = options.sdk || getPoolSdkName();
|
|
288
|
+
this.#model = options.model;
|
|
289
|
+
this.#maxConcurrent =
|
|
290
|
+
options.maxConcurrentReviews ?? DEFAULT_MAX_CONCURRENT;
|
|
291
|
+
this.#reviewTimeoutMs =
|
|
292
|
+
options.reviewTimeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
|
|
293
|
+
this.#onReviewComplete = options.onReviewComplete;
|
|
294
|
+
this.#sendTelegram = options.sendTelegram;
|
|
295
|
+
this.#promptTemplate = options.promptTemplate;
|
|
296
|
+
console.log(
|
|
297
|
+
`${TAG} initialized (sdk=${this.#sdk}, maxConcurrent=${this.#maxConcurrent}, timeout=${this.#reviewTimeoutMs}ms)`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Queue a task for review.
|
|
303
|
+
* Deduplicates by task ID — same task won't be reviewed twice.
|
|
304
|
+
* @param {{ id: string, title: string, branchName: string, prUrl: string, description: string }} task
|
|
305
|
+
*/
|
|
306
|
+
async queueReview(task) {
|
|
307
|
+
if (!task?.id) {
|
|
308
|
+
console.warn(`${TAG} queueReview called without task id — skipping`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (this.#seen.has(task.id)) {
|
|
313
|
+
console.log(`${TAG} task ${task.id} already reviewed/queued — skipping`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.#seen.add(task.id);
|
|
318
|
+
this.#queue.push(task);
|
|
319
|
+
console.log(
|
|
320
|
+
`${TAG} queued review for task "${task.title}" (${task.id}), queue depth: ${this.#queue.length}`,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Kick processing if running
|
|
324
|
+
if (this.#running) {
|
|
325
|
+
this.#processQueue();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Cancel a pending (not yet started) review.
|
|
331
|
+
* Active reviews cannot be cancelled.
|
|
332
|
+
* @param {string} taskId
|
|
333
|
+
*/
|
|
334
|
+
cancelReview(taskId) {
|
|
335
|
+
const idx = this.#queue.findIndex((t) => t.id === taskId);
|
|
336
|
+
if (idx !== -1) {
|
|
337
|
+
this.#queue.splice(idx, 1);
|
|
338
|
+
this.#seen.delete(taskId);
|
|
339
|
+
console.log(`${TAG} cancelled queued review for task ${taskId}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get current review-agent status.
|
|
345
|
+
* @returns {{ running: boolean, sdk: string, activeReviews: number, queuedReviews: number, completedReviews: number }}
|
|
346
|
+
*/
|
|
347
|
+
getStatus() {
|
|
348
|
+
return {
|
|
349
|
+
running: this.#running,
|
|
350
|
+
sdk: this.#sdk,
|
|
351
|
+
activeReviews: this.#activeReviews.size,
|
|
352
|
+
queuedReviews: this.#queue.length,
|
|
353
|
+
completedReviews: this.#completedCount,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Start processing the review queue. */
|
|
358
|
+
start() {
|
|
359
|
+
this.#running = true;
|
|
360
|
+
console.log(`${TAG} started`);
|
|
361
|
+
this.#processQueue();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Stop gracefully — waits for active reviews to finish.
|
|
366
|
+
* @returns {Promise<void>}
|
|
367
|
+
*/
|
|
368
|
+
async stop() {
|
|
369
|
+
this.#running = false;
|
|
370
|
+
console.log(
|
|
371
|
+
`${TAG} stopping — waiting for ${this.#activeReviews.size} active review(s)`,
|
|
372
|
+
);
|
|
373
|
+
if (this.#activeReviews.size > 0) {
|
|
374
|
+
await Promise.allSettled([...this.#activeReviews.values()]);
|
|
375
|
+
}
|
|
376
|
+
console.log(`${TAG} stopped`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Private ──────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/** Process queued reviews up to concurrency limit. */
|
|
382
|
+
#processQueue() {
|
|
383
|
+
if (this.#processing || !this.#running) return;
|
|
384
|
+
this.#processing = true;
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
while (
|
|
388
|
+
this.#queue.length > 0 &&
|
|
389
|
+
this.#activeReviews.size < this.#maxConcurrent
|
|
390
|
+
) {
|
|
391
|
+
const task = this.#queue.shift();
|
|
392
|
+
const promise = this.#runReview(task)
|
|
393
|
+
.catch((err) => {
|
|
394
|
+
console.error(`${TAG} unhandled review error for ${task.id}:`, err);
|
|
395
|
+
})
|
|
396
|
+
.finally(() => {
|
|
397
|
+
this.#activeReviews.delete(task.id);
|
|
398
|
+
this.#completedCount++;
|
|
399
|
+
// Continue processing after slot freed
|
|
400
|
+
if (this.#running && this.#queue.length > 0) {
|
|
401
|
+
this.#processQueue();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
this.#activeReviews.set(task.id, promise);
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
this.#processing = false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Run a single review.
|
|
414
|
+
* @param {{ id: string, title: string, branchName: string, prUrl: string, description: string }} task
|
|
415
|
+
*/
|
|
416
|
+
async #runReview(task) {
|
|
417
|
+
console.log(`${TAG} starting review for "${task.title}" (${task.id})`);
|
|
418
|
+
|
|
419
|
+
// 1. Get PR diff
|
|
420
|
+
const { diff, truncated } = getPrDiff({
|
|
421
|
+
prUrl: task.prUrl,
|
|
422
|
+
branchName: task.branchName,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if (!diff) {
|
|
426
|
+
console.log(
|
|
427
|
+
`${TAG} no diff available for task ${task.id} — auto-approving`,
|
|
428
|
+
);
|
|
429
|
+
const result = {
|
|
430
|
+
approved: true,
|
|
431
|
+
issues: [],
|
|
432
|
+
summary: "No diff available",
|
|
433
|
+
reviewedAt: new Date().toISOString(),
|
|
434
|
+
agentOutput: "",
|
|
435
|
+
};
|
|
436
|
+
this.#emitResult(task.id, result);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (truncated) {
|
|
441
|
+
console.warn(
|
|
442
|
+
`${TAG} diff for task ${task.id} truncated to ${MAX_DIFF_CHARS} chars`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 2. Build prompt
|
|
447
|
+
const prompt = buildReviewPrompt(
|
|
448
|
+
diff,
|
|
449
|
+
task.description,
|
|
450
|
+
this.#promptTemplate,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// 3. Run agent
|
|
454
|
+
let agentOutput = "";
|
|
455
|
+
try {
|
|
456
|
+
const sdkResult = await execWithRetry(prompt, {
|
|
457
|
+
taskKey: `review-${task.id}`,
|
|
458
|
+
timeoutMs: this.#reviewTimeoutMs,
|
|
459
|
+
maxRetries: 0, // Reviews don't retry — approve on failure
|
|
460
|
+
sdk: this.#sdk,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
agentOutput = sdkResult.output || "";
|
|
464
|
+
} catch (err) {
|
|
465
|
+
console.error(`${TAG} SDK call failed for task ${task.id}:`, err.message);
|
|
466
|
+
const result = {
|
|
467
|
+
approved: true,
|
|
468
|
+
issues: [],
|
|
469
|
+
summary: `Review failed: ${err.message}`,
|
|
470
|
+
reviewedAt: new Date().toISOString(),
|
|
471
|
+
agentOutput: "",
|
|
472
|
+
};
|
|
473
|
+
this.#emitResult(task.id, result);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 4. Parse result
|
|
478
|
+
const parsed = parseReviewResult(agentOutput);
|
|
479
|
+
|
|
480
|
+
const result = {
|
|
481
|
+
approved: parsed.approved,
|
|
482
|
+
issues: parsed.issues,
|
|
483
|
+
summary: parsed.summary + (truncated ? " (diff was truncated)" : ""),
|
|
484
|
+
reviewedAt: new Date().toISOString(),
|
|
485
|
+
agentOutput: agentOutput.slice(0, 3000),
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
console.log(
|
|
489
|
+
`${TAG} review complete for "${task.title}": ${result.approved ? "APPROVED" : "CHANGES REQUESTED"} — ${result.issues.length} issue(s)`,
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// 5. Report
|
|
493
|
+
this.#emitResult(task.id, result);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Emit review result via callback and optional Telegram notification.
|
|
498
|
+
* @param {string} taskId
|
|
499
|
+
* @param {Object} result
|
|
500
|
+
*/
|
|
501
|
+
#emitResult(taskId, result) {
|
|
502
|
+
if (typeof this.#onReviewComplete === "function") {
|
|
503
|
+
try {
|
|
504
|
+
this.#onReviewComplete(taskId, result);
|
|
505
|
+
} catch (err) {
|
|
506
|
+
console.error(`${TAG} onReviewComplete callback error:`, err.message);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Send Telegram for rejected reviews
|
|
511
|
+
if (!result.approved && typeof this.#sendTelegram === "function") {
|
|
512
|
+
const issueList = result.issues
|
|
513
|
+
.map(
|
|
514
|
+
(i) =>
|
|
515
|
+
`• [${i.severity}/${i.category}] ${i.file}${i.line ? `:${i.line}` : ""} — ${i.description}`,
|
|
516
|
+
)
|
|
517
|
+
.join("\n");
|
|
518
|
+
|
|
519
|
+
const message = [
|
|
520
|
+
`🔍 Review: changes requested`,
|
|
521
|
+
`Task: ${taskId}`,
|
|
522
|
+
`Summary: ${result.summary}`,
|
|
523
|
+
result.issues.length ? `\nIssues:\n${issueList}` : "",
|
|
524
|
+
]
|
|
525
|
+
.filter(Boolean)
|
|
526
|
+
.join("\n");
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
this.#sendTelegram(message);
|
|
530
|
+
} catch {
|
|
531
|
+
/* best effort */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Factory
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Create a ReviewAgent instance.
|
|
543
|
+
* @param {Object} [options] - Same options as ReviewAgent constructor
|
|
544
|
+
* @returns {ReviewAgent}
|
|
545
|
+
*/
|
|
546
|
+
export function createReviewAgent(options) {
|
|
547
|
+
let promptTemplate = options?.promptTemplate;
|
|
548
|
+
if (!promptTemplate) {
|
|
549
|
+
try {
|
|
550
|
+
const config = loadConfig();
|
|
551
|
+
promptTemplate = config.agentPrompts?.reviewer;
|
|
552
|
+
} catch {
|
|
553
|
+
/* best effort */
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return new ReviewAgent({ ...(options || {}), promptTemplate });
|
|
557
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Agent Work Log Rotation Script
|
|
4
|
+
#
|
|
5
|
+
# Rotates and compresses agent work logs to prevent unbounded growth.
|
|
6
|
+
# Intended to run weekly via cron or manually.
|
|
7
|
+
#
|
|
8
|
+
# Usage: bash scripts/openfleet/rotate-agent-logs.sh
|
|
9
|
+
|
|
10
|
+
set -e
|
|
11
|
+
|
|
12
|
+
# ── Configuration ───────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
15
|
+
LOG_DIR="$REPO_ROOT/.cache/agent-work-logs"
|
|
16
|
+
ARCHIVE_DIR="$LOG_DIR/archive"
|
|
17
|
+
|
|
18
|
+
# Retention periods
|
|
19
|
+
STREAM_RETENTION_DAYS=30
|
|
20
|
+
ERROR_RETENTION_DAYS=90
|
|
21
|
+
SESSION_RETENTION_COUNT=100
|
|
22
|
+
ARCHIVE_RETENTION_DAYS=180
|
|
23
|
+
|
|
24
|
+
# ── Functions ───────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
log() {
|
|
27
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ensure_dir() {
|
|
31
|
+
if [ ! -d "$1" ]; then
|
|
32
|
+
mkdir -p "$1"
|
|
33
|
+
log "Created directory: $1"
|
|
34
|
+
fi
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
rotate_file() {
|
|
38
|
+
local file="$1"
|
|
39
|
+
local archive_name="$2"
|
|
40
|
+
local retention_days="$3"
|
|
41
|
+
|
|
42
|
+
if [ ! -f "$file" ]; then
|
|
43
|
+
log "Skipping $file (not found)"
|
|
44
|
+
return
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
local size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo 0)
|
|
48
|
+
if [ "$size" -eq 0 ]; then
|
|
49
|
+
log "Skipping $file (empty)"
|
|
50
|
+
return
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Compress and archive
|
|
54
|
+
log "Archiving $file → $archive_name ($(numfmt --to=iec-i --suffix=B $size))"
|
|
55
|
+
gzip -c "$file" > "$ARCHIVE_DIR/$archive_name"
|
|
56
|
+
|
|
57
|
+
# Truncate original file
|
|
58
|
+
> "$file"
|
|
59
|
+
log "Truncated $file"
|
|
60
|
+
|
|
61
|
+
# Clean old archives
|
|
62
|
+
if [ -n "$retention_days" ]; then
|
|
63
|
+
find "$ARCHIVE_DIR" -name "$(basename "$archive_name" .gz)*" -type f -mtime +$retention_days -delete
|
|
64
|
+
log "Cleaned archives older than $retention_days days"
|
|
65
|
+
fi
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
clean_sessions() {
|
|
69
|
+
local session_dir="$LOG_DIR/agent-sessions"
|
|
70
|
+
local retention_count=$1
|
|
71
|
+
|
|
72
|
+
if [ ! -d "$session_dir" ]; then
|
|
73
|
+
return
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
local session_count=$(find "$session_dir" -name "*.jsonl" -type f | wc -l)
|
|
77
|
+
if [ "$session_count" -le "$retention_count" ]; then
|
|
78
|
+
log "Session logs: $session_count/$retention_count (within limit)"
|
|
79
|
+
return
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# Delete oldest sessions beyond retention limit
|
|
83
|
+
log "Cleaning old session logs (keeping $retention_count newest)"
|
|
84
|
+
ls -t "$session_dir"/*.jsonl | tail -n +$((retention_count + 1)) | xargs rm -f
|
|
85
|
+
log "Deleted $((session_count - retention_count)) old session logs"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# ── Main ────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
log "Starting agent work log rotation"
|
|
91
|
+
|
|
92
|
+
# Ensure directories exist
|
|
93
|
+
ensure_dir "$LOG_DIR"
|
|
94
|
+
ensure_dir "$ARCHIVE_DIR"
|
|
95
|
+
|
|
96
|
+
# Rotate main stream log
|
|
97
|
+
if [ -f "$LOG_DIR/agent-work-stream.jsonl" ]; then
|
|
98
|
+
STREAM_ARCHIVE="agent-work-stream-$(date +%Y%m%d).jsonl.gz"
|
|
99
|
+
rotate_file "$LOG_DIR/agent-work-stream.jsonl" "$STREAM_ARCHIVE" "$STREAM_RETENTION_DAYS"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# Rotate error log
|
|
103
|
+
if [ -f "$LOG_DIR/agent-errors.jsonl" ]; then
|
|
104
|
+
ERROR_ARCHIVE="agent-errors-$(date +%Y%m%d).jsonl.gz"
|
|
105
|
+
rotate_file "$LOG_DIR/agent-errors.jsonl" "$ERROR_ARCHIVE" "$ERROR_RETENTION_DAYS"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
# Rotate alerts log
|
|
109
|
+
if [ -f "$LOG_DIR/agent-alerts.jsonl" ]; then
|
|
110
|
+
ALERTS_ARCHIVE="agent-alerts-$(date +%Y%m%d).jsonl.gz"
|
|
111
|
+
rotate_file "$LOG_DIR/agent-alerts.jsonl" "$ALERTS_ARCHIVE" "$STREAM_RETENTION_DAYS"
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# Metrics log is kept indefinitely (compressed monthly)
|
|
115
|
+
if [ -f "$LOG_DIR/agent-metrics.jsonl" ]; then
|
|
116
|
+
# Only rotate on first day of month
|
|
117
|
+
if [ "$(date +%d)" = "01" ]; then
|
|
118
|
+
METRICS_ARCHIVE="agent-metrics-$(date -d 'last month' +%Y%m).jsonl.gz"
|
|
119
|
+
rotate_file "$LOG_DIR/agent-metrics.jsonl" "$METRICS_ARCHIVE" ""
|
|
120
|
+
fi
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
# Clean old session logs
|
|
124
|
+
clean_sessions "$SESSION_RETENTION_COUNT"
|
|
125
|
+
|
|
126
|
+
# Archive statistics
|
|
127
|
+
if [ -d "$ARCHIVE_DIR" ]; then
|
|
128
|
+
archive_count=$(find "$ARCHIVE_DIR" -name "*.gz" -type f | wc -l)
|
|
129
|
+
archive_size=$(du -sh "$ARCHIVE_DIR" 2>/dev/null | cut -f1 || echo "0")
|
|
130
|
+
log "Archive directory: $archive_count files, $archive_size total"
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
log "Agent work log rotation completed"
|