fission-worker 0.2.2 → 0.3.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/dist/docker.js +4 -5
- package/dist/docker.js.map +1 -1
- package/package.json +4 -3
- package/runner/common/decorators/current-user.decorator.js +10 -0
- package/runner/common/decorators/public.decorator.js +7 -0
- package/runner/common/decorators/roles.decorator.js +7 -0
- package/runner/common/services/activity-log.service.js +48 -0
- package/runner/common/services/event-bus.service.js +38 -0
- package/runner/modules/github/github.controller.js +155 -0
- package/runner/modules/github/github.module.js +22 -0
- package/runner/modules/github/github.service.js +104 -0
- package/runner/modules/pipeline/data/api-data.service.js +140 -0
- package/runner/modules/pipeline/data/pipeline-data.interface.js +2 -0
- package/runner/modules/pipeline/data/prisma-data.service.js +149 -0
- package/runner/modules/pipeline/pipeline-cto.service.js +129 -0
- package/runner/modules/pipeline/pipeline-helpers.service.js +318 -0
- package/runner/modules/pipeline/pipeline-orchestrator.js +399 -0
- package/runner/modules/pipeline/pipeline-queue.service.js +121 -0
- package/runner/modules/pipeline/pipeline-techlead.service.js +127 -0
- package/runner/modules/pipeline/pipeline-worker.service.js +343 -0
- package/runner/modules/pipeline/pipeline.controller.js +310 -0
- package/runner/modules/pipeline/pipeline.module.js +51 -0
- package/runner/modules/pipeline/pipeline.service.js +706 -0
- package/runner/modules/worker-api/worker-api.controller.js +497 -0
- package/runner/modules/worker-api/worker-api.guard.js +41 -0
- package/runner/modules/worker-api/worker-api.module.js +25 -0
- package/runner/modules/worker-api/worker-dispatch.service.js +87 -0
- package/runner/pipeline-runner/index.js +108 -0
- package/runner/prisma/prisma.service.js +23 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
var PipelineWorkerService_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.PipelineWorkerService = void 0;
|
|
17
|
+
const common_1 = require("@nestjs/common");
|
|
18
|
+
const common_2 = require("@nestjs/common");
|
|
19
|
+
const pipeline_helpers_service_1 = require("./pipeline-helpers.service");
|
|
20
|
+
let PipelineWorkerService = PipelineWorkerService_1 = class PipelineWorkerService {
|
|
21
|
+
data;
|
|
22
|
+
helpers;
|
|
23
|
+
logger = new common_1.Logger(PipelineWorkerService_1.name);
|
|
24
|
+
/** Persistent Claude session IDs per worker slot — reuse context across tasks. */
|
|
25
|
+
slotSessions = new Map();
|
|
26
|
+
constructor(data, helpers) {
|
|
27
|
+
this.data = data;
|
|
28
|
+
this.helpers = helpers;
|
|
29
|
+
}
|
|
30
|
+
/** Clear all persistent sessions (call between pipeline runs). */
|
|
31
|
+
clearSessions() {
|
|
32
|
+
this.slotSessions.clear();
|
|
33
|
+
}
|
|
34
|
+
// ------------------------------------------------------------------ //
|
|
35
|
+
// Build Agent — builds a task, marks IN_REVIEW if diff exists //
|
|
36
|
+
// ------------------------------------------------------------------ //
|
|
37
|
+
async runBuildAgent(task, projectId, repoPaths, repoPath, sessionId, projectContext, slot = 0) {
|
|
38
|
+
const MAX_RETRIES = 3;
|
|
39
|
+
// Check retry limit BEFORE doing any work
|
|
40
|
+
if (task.retryCount >= MAX_RETRIES) {
|
|
41
|
+
this.logger.warn(`[${sessionId}] Task "${task.title}" exceeded max retries (${task.retryCount}/${MAX_RETRIES}) — marking FAILED`);
|
|
42
|
+
await this.data.updateTask(task.id, { status: "FAILED" });
|
|
43
|
+
await this.data.createTaskComment({
|
|
44
|
+
taskId: task.id,
|
|
45
|
+
author: "worker",
|
|
46
|
+
content: `Max retries exceeded (${task.retryCount}/${MAX_RETRIES}). Task permanently failed.`,
|
|
47
|
+
});
|
|
48
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: "FAILED" });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Track what this worker slot is doing
|
|
52
|
+
this.helpers.workerTasks.set(slot, task.title);
|
|
53
|
+
this.helpers.emitAgentState(projectId);
|
|
54
|
+
// Capture git HEAD before worker starts (stored in metadata for QA later)
|
|
55
|
+
let preWorkerHead;
|
|
56
|
+
try {
|
|
57
|
+
preWorkerHead = this.helpers.gitRevParse(repoPath);
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
// Move to IN_PROGRESS
|
|
61
|
+
await this.data.updateTask(task.id, { status: "IN_PROGRESS" });
|
|
62
|
+
await this.data.createTaskComment({
|
|
63
|
+
taskId: task.id,
|
|
64
|
+
author: "worker",
|
|
65
|
+
content: `Started working on this task (attempt ${task.retryCount + 1}/${MAX_RETRIES})`,
|
|
66
|
+
});
|
|
67
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: "IN_PROGRESS" });
|
|
68
|
+
this.data.emit(projectId, "build_progress", { taskId: task.id, taskTitle: task.title, status: "starting" });
|
|
69
|
+
this.logger.log(`[${sessionId}] Worker: building "${task.title}" (attempt ${task.retryCount + 1}/${MAX_RETRIES})`);
|
|
70
|
+
try {
|
|
71
|
+
// Fetch task comment history so the worker has full context
|
|
72
|
+
const taskComments = await this.data.findTaskComments(task.id, {
|
|
73
|
+
orderBy: { createdAt: "asc" },
|
|
74
|
+
take: 20,
|
|
75
|
+
});
|
|
76
|
+
const commentHistory = taskComments.length > 0
|
|
77
|
+
? `\nTask history (read carefully — includes QA feedback and previous attempts):\n${taskComments.map((c) => `[${c.author}]: ${c.content}`).join("\n")}\n`
|
|
78
|
+
: "";
|
|
79
|
+
const workerPrompt = (projectContext ? `Project context:\n${projectContext}\n\n` : "") +
|
|
80
|
+
`You are a developer. Implement this task: ${task.title}. ` +
|
|
81
|
+
`Objective: ${task.objective || "No additional details."}. ` +
|
|
82
|
+
commentHistory +
|
|
83
|
+
`The project has the following repositories:\n${repoPaths}\n\n` +
|
|
84
|
+
`Decide which repo(s) to work in based on the task. Make the changes, then commit with a descriptive message. ` +
|
|
85
|
+
`Report what you did as JSON with fields: summary (string), filesChanged (string array). ` +
|
|
86
|
+
`Return ONLY valid JSON.`;
|
|
87
|
+
// Effort-based timeout (generous — Claude needs time for context + plan + implement + commit)
|
|
88
|
+
const taskTimeout = task.effort === "small"
|
|
89
|
+
? 10 * 60 * 1000 // 10 minutes
|
|
90
|
+
: task.effort === "large"
|
|
91
|
+
? 30 * 60 * 1000 // 30 minutes
|
|
92
|
+
: 15 * 60 * 1000; // 15 minutes (medium default)
|
|
93
|
+
// Use persistent session: resume if this slot already has a session, otherwise start new
|
|
94
|
+
const existingSession = this.slotSessions.get(slot);
|
|
95
|
+
const workerResult = await this.helpers.spawnClaude(workerPrompt, repoPath, undefined, taskTimeout, { projectId, phase: "BUILD" }, { resumeSessionId: existingSession, jsonOutput: true });
|
|
96
|
+
// Capture session ID for future tasks in this slot
|
|
97
|
+
if (workerResult.sessionId) {
|
|
98
|
+
this.slotSessions.set(slot, workerResult.sessionId);
|
|
99
|
+
}
|
|
100
|
+
const workerOutput = this.helpers.parseClaudeJson(workerResult.stdout);
|
|
101
|
+
if (workerResult.exitCode === 0) {
|
|
102
|
+
// Check if worker actually produced a diff
|
|
103
|
+
let postWorkerHead;
|
|
104
|
+
let hasDiff = false;
|
|
105
|
+
if (preWorkerHead) {
|
|
106
|
+
try {
|
|
107
|
+
postWorkerHead = this.helpers.gitRevParse(repoPath);
|
|
108
|
+
hasDiff = preWorkerHead !== postWorkerHead;
|
|
109
|
+
}
|
|
110
|
+
catch { }
|
|
111
|
+
}
|
|
112
|
+
if (!hasDiff) {
|
|
113
|
+
// No diff — check if worker actually failed despite exit code 0
|
|
114
|
+
const summaryText = workerOutput?.summary ?? workerResult.stdout.slice(0, 2000);
|
|
115
|
+
const failureIndicators = ["unable to", "could not", "blocked", "not applied", "not granted", "were denied", "was denied", "permission"];
|
|
116
|
+
const workerActuallyFailed = failureIndicators.some((indicator) => summaryText.toLowerCase().includes(indicator));
|
|
117
|
+
if (workerActuallyFailed) {
|
|
118
|
+
const updatedTask = await this.data.updateTask(task.id, {
|
|
119
|
+
status: (task.retryCount + 1) >= 3 ? "FAILED" : "READY_TO_START",
|
|
120
|
+
retryCount: { increment: 1 },
|
|
121
|
+
metadata: (workerOutput ?? { rawOutput: summaryText }),
|
|
122
|
+
});
|
|
123
|
+
await this.data.createTaskComment({
|
|
124
|
+
taskId: task.id,
|
|
125
|
+
author: "worker",
|
|
126
|
+
content: `Failed (no code changes made, attempt ${task.retryCount + 1}/3): ${summaryText.slice(0, 300)}`,
|
|
127
|
+
});
|
|
128
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: updatedTask.status });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Genuinely completed without code changes (e.g., investigation task)
|
|
132
|
+
await this.data.updateTask(task.id, {
|
|
133
|
+
status: "COMPLETED",
|
|
134
|
+
metadata: (workerOutput ?? { rawOutput: workerResult.stdout.slice(0, 2000) }),
|
|
135
|
+
});
|
|
136
|
+
await this.data.createTaskComment({
|
|
137
|
+
taskId: task.id,
|
|
138
|
+
author: "worker",
|
|
139
|
+
content: workerOutput?.summary
|
|
140
|
+
? `Completed: ${workerOutput.summary}${workerOutput.filesChanged ? `\nFiles changed: ${workerOutput.filesChanged.join(", ")}` : ""}`
|
|
141
|
+
: `Task completed (exit code: ${workerResult.exitCode})`,
|
|
142
|
+
});
|
|
143
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: "COMPLETED" });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Has diff — move to IN_REVIEW and store heads for QA to pick up later
|
|
148
|
+
await this.data.updateTask(task.id, {
|
|
149
|
+
status: "IN_REVIEW",
|
|
150
|
+
metadata: {
|
|
151
|
+
...(workerOutput ?? { rawOutput: workerResult.stdout.slice(0, 2000) }),
|
|
152
|
+
preWorkerHead,
|
|
153
|
+
postWorkerHead,
|
|
154
|
+
repoPath,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
await this.data.createTaskComment({
|
|
158
|
+
taskId: task.id,
|
|
159
|
+
author: "worker",
|
|
160
|
+
content: workerOutput?.summary
|
|
161
|
+
? `Build complete, awaiting QA: ${workerOutput.summary}${workerOutput.filesChanged ? `\nFiles changed: ${workerOutput.filesChanged.join(", ")}` : ""}`
|
|
162
|
+
: `Build complete (exit code: ${workerResult.exitCode}), awaiting QA review.`,
|
|
163
|
+
});
|
|
164
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: "IN_REVIEW" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// Non-zero exit code — increment retry count and decide status
|
|
169
|
+
const newRetryCount = task.retryCount + 1;
|
|
170
|
+
const finalStatus = newRetryCount >= MAX_RETRIES ? "FAILED" : "READY_TO_START";
|
|
171
|
+
await this.data.updateTask(task.id, {
|
|
172
|
+
status: finalStatus,
|
|
173
|
+
retryCount: { increment: 1 },
|
|
174
|
+
metadata: (workerOutput ?? { rawOutput: workerResult.stdout.slice(0, 2000) }),
|
|
175
|
+
});
|
|
176
|
+
await this.data.createTaskComment({
|
|
177
|
+
taskId: task.id,
|
|
178
|
+
author: "worker",
|
|
179
|
+
content: newRetryCount >= MAX_RETRIES
|
|
180
|
+
? `Failed (exit code: ${workerResult.exitCode}). Max retries exceeded (${newRetryCount}/${MAX_RETRIES}). Task permanently failed.`
|
|
181
|
+
: `Failed (exit code: ${workerResult.exitCode}). Retry ${newRetryCount}/${MAX_RETRIES} — sent back to Ready to Start.`,
|
|
182
|
+
});
|
|
183
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: finalStatus });
|
|
184
|
+
}
|
|
185
|
+
this.data.emit(projectId, "build_progress", {
|
|
186
|
+
taskId: task.id,
|
|
187
|
+
taskTitle: task.title,
|
|
188
|
+
status: workerResult.exitCode === 0 ? "done" : "failed",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
this.logger.warn(`Worker failed on task "${task.title}": ${err}`);
|
|
193
|
+
const newRetryCount = task.retryCount + 1;
|
|
194
|
+
const finalStatus = newRetryCount >= MAX_RETRIES ? "FAILED" : "READY_TO_START";
|
|
195
|
+
await this.data.updateTask(task.id, {
|
|
196
|
+
status: finalStatus,
|
|
197
|
+
retryCount: { increment: 1 },
|
|
198
|
+
metadata: { error: String(err) },
|
|
199
|
+
});
|
|
200
|
+
await this.data.createTaskComment({
|
|
201
|
+
taskId: task.id,
|
|
202
|
+
author: "worker",
|
|
203
|
+
content: newRetryCount >= MAX_RETRIES
|
|
204
|
+
? `Failed: ${String(err) || "Unknown error"}. Max retries exceeded (${newRetryCount}/${MAX_RETRIES}). Task permanently failed.`
|
|
205
|
+
: `Failed: ${String(err) || "Unknown error"}. Retry ${newRetryCount}/${MAX_RETRIES} — sent back to Ready to Start.`,
|
|
206
|
+
});
|
|
207
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: finalStatus });
|
|
208
|
+
this.data.emit(projectId, "build_progress", {
|
|
209
|
+
taskId: task.id,
|
|
210
|
+
taskTitle: task.title,
|
|
211
|
+
status: "failed",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
// Clear worker slot tracking
|
|
216
|
+
this.helpers.workerTasks.delete(slot);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// ------------------------------------------------------------------ //
|
|
220
|
+
// QA Agent — reviews an IN_REVIEW task (decoupled from build) //
|
|
221
|
+
// ------------------------------------------------------------------ //
|
|
222
|
+
async runQaAgent(task, projectId, repoPath, slot = 0) {
|
|
223
|
+
const MAX_RETRIES = 3;
|
|
224
|
+
this.helpers.workerTasks.set(slot, `QA: ${task.title}`);
|
|
225
|
+
this.helpers.emitAgentState(projectId);
|
|
226
|
+
try {
|
|
227
|
+
// Read diff heads from task metadata (stored by build agent)
|
|
228
|
+
const meta = task.metadata || {};
|
|
229
|
+
const preHead = meta.preWorkerHead;
|
|
230
|
+
const postHead = meta.postWorkerHead;
|
|
231
|
+
const taskRepoPath = meta.repoPath || repoPath;
|
|
232
|
+
let diffForReview = "";
|
|
233
|
+
if (preHead && postHead) {
|
|
234
|
+
diffForReview = this.helpers.gitDiff(taskRepoPath, preHead, postHead);
|
|
235
|
+
}
|
|
236
|
+
if (!diffForReview) {
|
|
237
|
+
// No diff available — approve by default
|
|
238
|
+
this.logger.warn(`[QA] No diff found for task "${task.title}" — approving by default`);
|
|
239
|
+
await this.data.updateTask(task.id, { status: "COMPLETED" });
|
|
240
|
+
await this.data.createTaskComment({
|
|
241
|
+
taskId: task.id,
|
|
242
|
+
author: "qa-agent",
|
|
243
|
+
content: "QA approved (no diff available for review).",
|
|
244
|
+
});
|
|
245
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: "COMPLETED" });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Run QA review
|
|
249
|
+
const qaPrompt = `You are a QA reviewer. Review this code change for task: "${task.title}"\n` +
|
|
250
|
+
`Objective: ${task.objective || "N/A"}\n\n` +
|
|
251
|
+
`Git diff:\n${diffForReview.slice(0, 10000)}\n\n` +
|
|
252
|
+
`Check for: bugs, missing error handling, security issues, incomplete implementation.\n` +
|
|
253
|
+
`Return JSON: {approved: boolean, summary: string, issues: string[]}\n` +
|
|
254
|
+
`ONLY valid JSON.`;
|
|
255
|
+
const qaResult = await this.helpers.spawnClaude(qaPrompt, taskRepoPath, "claude-sonnet-4-6", 5 * 60 * 1000, { projectId, phase: "REVIEW" });
|
|
256
|
+
const qaOutput = this.helpers.parseClaudeJson(qaResult.stdout);
|
|
257
|
+
let qaApproved = true;
|
|
258
|
+
let qaSummary = "";
|
|
259
|
+
let qaIssues = [];
|
|
260
|
+
if (qaOutput && typeof qaOutput.approved === "boolean") {
|
|
261
|
+
qaApproved = qaOutput.approved;
|
|
262
|
+
qaSummary = qaOutput.summary || "";
|
|
263
|
+
qaIssues = qaOutput.issues || [];
|
|
264
|
+
}
|
|
265
|
+
this.data.emit(projectId, "qa_review", { taskId: task.id, approved: qaApproved, issues: qaIssues });
|
|
266
|
+
if (qaApproved) {
|
|
267
|
+
await this.data.updateTask(task.id, { status: "COMPLETED" });
|
|
268
|
+
await this.data.createTaskComment({
|
|
269
|
+
taskId: task.id,
|
|
270
|
+
author: "qa-agent",
|
|
271
|
+
content: `QA approved. ${qaSummary}`,
|
|
272
|
+
});
|
|
273
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: "COMPLETED" });
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// QA rejected — send back to developer
|
|
277
|
+
const newRetryCount = task.retryCount + 1;
|
|
278
|
+
const finalStatus = newRetryCount >= MAX_RETRIES ? "FAILED" : "READY_TO_START";
|
|
279
|
+
await this.data.updateTask(task.id, {
|
|
280
|
+
status: finalStatus,
|
|
281
|
+
retryCount: { increment: 1 },
|
|
282
|
+
});
|
|
283
|
+
await this.data.createTaskComment({
|
|
284
|
+
taskId: task.id,
|
|
285
|
+
author: "qa-agent",
|
|
286
|
+
content: `QA rejected (attempt ${task.retryCount + 1}/${MAX_RETRIES}). ${qaSummary}\nIssues:\n${qaIssues.map((i) => `- ${i}`).join("\n")}`,
|
|
287
|
+
});
|
|
288
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: finalStatus });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (qaErr) {
|
|
292
|
+
// QA agent failure — approve by default so pipeline isn't blocked
|
|
293
|
+
this.logger.warn(`QA agent failed for task "${task.title}": ${qaErr} — approving by default`);
|
|
294
|
+
await this.data.updateTask(task.id, { status: "COMPLETED" });
|
|
295
|
+
await this.data.createTaskComment({
|
|
296
|
+
taskId: task.id,
|
|
297
|
+
author: "qa-agent",
|
|
298
|
+
content: `QA agent error — approved by default. Error: ${String(qaErr).slice(0, 200)}`,
|
|
299
|
+
});
|
|
300
|
+
this.data.emit(projectId, "task_updated", { id: task.id, status: "COMPLETED" });
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
this.helpers.workerTasks.delete(slot);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// ------------------------------------------------------------------ //
|
|
307
|
+
// Replan — archive failed task, reset finding to NEW for Tech Lead //
|
|
308
|
+
// ------------------------------------------------------------------ //
|
|
309
|
+
async replanFailedTask(task) {
|
|
310
|
+
// Archive the failed task
|
|
311
|
+
await this.data.updateTask(task.id, { status: "ARCHIVED" });
|
|
312
|
+
if (task.findingId) {
|
|
313
|
+
// Reset linked finding to NEW so Tech Lead re-plans it
|
|
314
|
+
await this.data.updateFinding(task.findingId, { status: "NEW" });
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
// No linked finding — create one from the task
|
|
318
|
+
await this.data.createFinding({
|
|
319
|
+
projectId: task.projectId,
|
|
320
|
+
title: task.title,
|
|
321
|
+
description: task.objective || `Re-plan failed task: ${task.title}`,
|
|
322
|
+
severity: "MEDIUM",
|
|
323
|
+
category: "FEATURE",
|
|
324
|
+
status: "NEW",
|
|
325
|
+
source: "replan",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
await this.data.createTaskComment({
|
|
329
|
+
taskId: task.id,
|
|
330
|
+
author: "pipeline",
|
|
331
|
+
content: "Auto-replanned: task archived, finding sent back to Tech Lead for re-assessment.",
|
|
332
|
+
});
|
|
333
|
+
this.data.emit(task.projectId, "task_updated", { id: task.id, status: "ARCHIVED" });
|
|
334
|
+
this.logger.log(`[Replan] Task "${task.title}" archived and finding reset to NEW`);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
exports.PipelineWorkerService = PipelineWorkerService;
|
|
338
|
+
exports.PipelineWorkerService = PipelineWorkerService = PipelineWorkerService_1 = __decorate([
|
|
339
|
+
(0, common_1.Injectable)(),
|
|
340
|
+
__param(0, (0, common_2.Optional)()),
|
|
341
|
+
__param(0, (0, common_2.Inject)("PIPELINE_DATA")),
|
|
342
|
+
__metadata("design:paramtypes", [Object, pipeline_helpers_service_1.PipelineHelpersService])
|
|
343
|
+
], PipelineWorkerService);
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
var PipelineController_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.PipelineController = void 0;
|
|
17
|
+
const common_1 = require("@nestjs/common");
|
|
18
|
+
const jwt_1 = require("@nestjs/jwt");
|
|
19
|
+
const config_1 = require("@nestjs/config");
|
|
20
|
+
const throttler_1 = require("@nestjs/throttler");
|
|
21
|
+
const client_1 = require("@prisma/client");
|
|
22
|
+
const roles_decorator_1 = require("../../common/decorators/roles.decorator");
|
|
23
|
+
const current_user_decorator_1 = require("../../common/decorators/current-user.decorator");
|
|
24
|
+
const public_decorator_1 = require("../../common/decorators/public.decorator");
|
|
25
|
+
const event_bus_service_1 = require("../../common/services/event-bus.service");
|
|
26
|
+
const pipeline_service_1 = require("./pipeline.service");
|
|
27
|
+
const pipeline_queue_service_1 = require("./pipeline-queue.service");
|
|
28
|
+
let PipelineController = PipelineController_1 = class PipelineController {
|
|
29
|
+
pipelineService;
|
|
30
|
+
pipelineQueueService;
|
|
31
|
+
eventBus;
|
|
32
|
+
jwtService;
|
|
33
|
+
configService;
|
|
34
|
+
logger = new common_1.Logger(PipelineController_1.name);
|
|
35
|
+
constructor(pipelineService, pipelineQueueService, eventBus, jwtService, configService) {
|
|
36
|
+
this.pipelineService = pipelineService;
|
|
37
|
+
this.pipelineQueueService = pipelineQueueService;
|
|
38
|
+
this.eventBus = eventBus;
|
|
39
|
+
this.jwtService = jwtService;
|
|
40
|
+
this.configService = configService;
|
|
41
|
+
}
|
|
42
|
+
/** GET /pipeline/queue/status — global queue status (must be before :projectId routes). */
|
|
43
|
+
async getQueueStatus() {
|
|
44
|
+
return { data: this.pipelineQueueService.getStatus() };
|
|
45
|
+
}
|
|
46
|
+
/** GET /pipeline/agents — current agent state derived from DB (persistent, accurate). */
|
|
47
|
+
async getAgentState() {
|
|
48
|
+
return { data: await this.pipelineService.getAgentStateFromDb() };
|
|
49
|
+
}
|
|
50
|
+
/** GET /pipeline/history — paginated history of all pipeline sessions. */
|
|
51
|
+
async getPipelineHistory(projectId, page, limit) {
|
|
52
|
+
return this.pipelineService.getPipelineHistory({
|
|
53
|
+
projectId: projectId || undefined,
|
|
54
|
+
page: parseInt(page || "1", 10) || 1,
|
|
55
|
+
limit: parseInt(limit || "20", 10) || 20,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* GET /pipeline/:projectId/events?token=<jwt> — SSE stream for real-time
|
|
60
|
+
* kanban updates. Auth is via query param because EventSource does not
|
|
61
|
+
* support custom headers.
|
|
62
|
+
*
|
|
63
|
+
* Marked @Public() to bypass the global JwtAuthGuard (we validate the
|
|
64
|
+
* token manually from the query string).
|
|
65
|
+
*/
|
|
66
|
+
async events(projectId, token, reply) {
|
|
67
|
+
// --- Manual JWT validation ---
|
|
68
|
+
if (!token) {
|
|
69
|
+
throw new common_1.UnauthorizedException("Missing token query parameter");
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const secret = this.configService.getOrThrow("ACCESS_JWT_SECRET");
|
|
73
|
+
this.jwtService.verify(token, { secret });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new common_1.UnauthorizedException("Invalid or expired token");
|
|
77
|
+
}
|
|
78
|
+
// --- Stream SSE via raw Node response ---
|
|
79
|
+
const raw = reply.raw;
|
|
80
|
+
raw.setHeader("Content-Type", "text/event-stream");
|
|
81
|
+
raw.setHeader("Cache-Control", "no-cache");
|
|
82
|
+
raw.setHeader("Connection", "keep-alive");
|
|
83
|
+
raw.setHeader("X-Accel-Buffering", "no"); // disable nginx buffering
|
|
84
|
+
raw.flushHeaders();
|
|
85
|
+
// Send an initial comment to confirm connection
|
|
86
|
+
raw.write(": connected\n\n");
|
|
87
|
+
// Keep-alive ping every 30 seconds so proxies don't close idle connections
|
|
88
|
+
const keepAlive = setInterval(() => {
|
|
89
|
+
raw.write(": ping\n\n");
|
|
90
|
+
}, 30_000);
|
|
91
|
+
// Subscribe to the event bus
|
|
92
|
+
const subscription = this.eventBus.getStream(projectId).subscribe({
|
|
93
|
+
next: (event) => {
|
|
94
|
+
raw.write(`data: ${event.data}\n\n`);
|
|
95
|
+
},
|
|
96
|
+
error: (err) => {
|
|
97
|
+
this.logger.error(`SSE stream error for project ${projectId}: ${err}`);
|
|
98
|
+
raw.end();
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
// Clean up when the client disconnects
|
|
102
|
+
raw.on("close", () => {
|
|
103
|
+
clearInterval(keepAlive);
|
|
104
|
+
subscription.unsubscribe();
|
|
105
|
+
this.logger.debug(`SSE client disconnected for project ${projectId}`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** POST /pipeline/:projectId/autopilot — toggle autopilot mode. */
|
|
109
|
+
async toggleAutopilot(projectId, enabled) {
|
|
110
|
+
await this.pipelineService.setAutopilot(projectId, enabled);
|
|
111
|
+
return { data: { enabled } };
|
|
112
|
+
}
|
|
113
|
+
/** GET /pipeline/:projectId/autopilot — get autopilot status. */
|
|
114
|
+
async getAutopilot(projectId) {
|
|
115
|
+
const enabled = await this.pipelineService.isAutopilotEnabled(projectId);
|
|
116
|
+
return { data: { enabled } };
|
|
117
|
+
}
|
|
118
|
+
/** POST /pipeline/:projectId/run — start the R&D pipeline (background). */
|
|
119
|
+
async runPipeline(projectId, userId) {
|
|
120
|
+
const result = await this.pipelineService.runPipeline(projectId, userId);
|
|
121
|
+
return { data: result };
|
|
122
|
+
}
|
|
123
|
+
/** POST /pipeline/:projectId/stop — stop the running pipeline. */
|
|
124
|
+
async stopPipeline(projectId, userId) {
|
|
125
|
+
const result = await this.pipelineService.stopPipeline(projectId, userId);
|
|
126
|
+
return { data: result };
|
|
127
|
+
}
|
|
128
|
+
/** POST /pipeline/:projectId/direction — send user direction to the pipeline. */
|
|
129
|
+
async sendDirection(projectId, direction, userId) {
|
|
130
|
+
if (!direction || typeof direction !== "string") {
|
|
131
|
+
throw new common_1.BadRequestException("Direction is required");
|
|
132
|
+
}
|
|
133
|
+
// Strip control characters (keep newlines and tabs for readability)
|
|
134
|
+
const sanitized = direction.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
|
|
135
|
+
if (!sanitized) {
|
|
136
|
+
throw new common_1.BadRequestException("Direction must not be empty");
|
|
137
|
+
}
|
|
138
|
+
if (sanitized.length > 500) {
|
|
139
|
+
throw new common_1.BadRequestException("Direction must be 500 characters or fewer");
|
|
140
|
+
}
|
|
141
|
+
const result = await this.pipelineService.sendDirection(projectId, sanitized, userId);
|
|
142
|
+
return { data: result };
|
|
143
|
+
}
|
|
144
|
+
/** POST /pipeline/:projectId/sync — sync docs/rnd/ to DB. */
|
|
145
|
+
async syncPipelineState(projectId) {
|
|
146
|
+
const result = await this.pipelineService.syncPipelineState(projectId);
|
|
147
|
+
return { data: result };
|
|
148
|
+
}
|
|
149
|
+
/** GET /pipeline/:projectId/sessions — get active sessions. */
|
|
150
|
+
async getActiveSessions(projectId) {
|
|
151
|
+
return this.pipelineService.getActiveSessions(projectId);
|
|
152
|
+
}
|
|
153
|
+
/** GET /pipeline/:projectId/sessions/:sessionId — get session detail with phase info. */
|
|
154
|
+
async getSession(sessionId) {
|
|
155
|
+
return this.pipelineService.getSession(sessionId);
|
|
156
|
+
}
|
|
157
|
+
/** GET /pipeline/:projectId/sessions/:sessionId/diff — get diff from worker phase. */
|
|
158
|
+
async getSessionDiff(sessionId) {
|
|
159
|
+
const result = await this.pipelineService.getSessionDiff(sessionId);
|
|
160
|
+
return { data: result };
|
|
161
|
+
}
|
|
162
|
+
/** GET /pipeline/:projectId/sessions/:sessionId/summary — get pipeline run summary. */
|
|
163
|
+
async getSessionSummary(sessionId) {
|
|
164
|
+
const result = await this.pipelineService.getSessionSummary(sessionId);
|
|
165
|
+
return { data: result };
|
|
166
|
+
}
|
|
167
|
+
/** POST /pipeline/:projectId/command — run arbitrary claude -p command. */
|
|
168
|
+
async runCommand(projectId, prompt, userId) {
|
|
169
|
+
const result = await this.pipelineService.runCommand(projectId, prompt, userId);
|
|
170
|
+
return { data: result };
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
exports.PipelineController = PipelineController;
|
|
174
|
+
__decorate([
|
|
175
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
176
|
+
(0, common_1.Get)("queue/status"),
|
|
177
|
+
__metadata("design:type", Function),
|
|
178
|
+
__metadata("design:paramtypes", []),
|
|
179
|
+
__metadata("design:returntype", Promise)
|
|
180
|
+
], PipelineController.prototype, "getQueueStatus", null);
|
|
181
|
+
__decorate([
|
|
182
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
183
|
+
(0, common_1.Get)("agents"),
|
|
184
|
+
__metadata("design:type", Function),
|
|
185
|
+
__metadata("design:paramtypes", []),
|
|
186
|
+
__metadata("design:returntype", Promise)
|
|
187
|
+
], PipelineController.prototype, "getAgentState", null);
|
|
188
|
+
__decorate([
|
|
189
|
+
(0, common_1.Get)("history"),
|
|
190
|
+
__param(0, (0, common_1.Query)("projectId")),
|
|
191
|
+
__param(1, (0, common_1.Query)("page")),
|
|
192
|
+
__param(2, (0, common_1.Query)("limit")),
|
|
193
|
+
__metadata("design:type", Function),
|
|
194
|
+
__metadata("design:paramtypes", [String, String, String]),
|
|
195
|
+
__metadata("design:returntype", Promise)
|
|
196
|
+
], PipelineController.prototype, "getPipelineHistory", null);
|
|
197
|
+
__decorate([
|
|
198
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
199
|
+
(0, common_1.Get)(":projectId/events"),
|
|
200
|
+
(0, public_decorator_1.Public)(),
|
|
201
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
202
|
+
__param(1, (0, common_1.Query)("token")),
|
|
203
|
+
__param(2, (0, common_1.Res)()),
|
|
204
|
+
__metadata("design:type", Function),
|
|
205
|
+
__metadata("design:paramtypes", [String, String, Object]),
|
|
206
|
+
__metadata("design:returntype", Promise)
|
|
207
|
+
], PipelineController.prototype, "events", null);
|
|
208
|
+
__decorate([
|
|
209
|
+
(0, common_1.Post)(":projectId/autopilot"),
|
|
210
|
+
(0, roles_decorator_1.Roles)(client_1.Role.ADMIN),
|
|
211
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
212
|
+
__param(1, (0, common_1.Body)("enabled")),
|
|
213
|
+
__metadata("design:type", Function),
|
|
214
|
+
__metadata("design:paramtypes", [String, Boolean]),
|
|
215
|
+
__metadata("design:returntype", Promise)
|
|
216
|
+
], PipelineController.prototype, "toggleAutopilot", null);
|
|
217
|
+
__decorate([
|
|
218
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
219
|
+
(0, common_1.Get)(":projectId/autopilot"),
|
|
220
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
221
|
+
__metadata("design:type", Function),
|
|
222
|
+
__metadata("design:paramtypes", [String]),
|
|
223
|
+
__metadata("design:returntype", Promise)
|
|
224
|
+
], PipelineController.prototype, "getAutopilot", null);
|
|
225
|
+
__decorate([
|
|
226
|
+
(0, common_1.Post)(":projectId/run"),
|
|
227
|
+
(0, roles_decorator_1.Roles)(client_1.Role.ADMIN),
|
|
228
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
229
|
+
__param(1, (0, current_user_decorator_1.CurrentUser)("id")),
|
|
230
|
+
__metadata("design:type", Function),
|
|
231
|
+
__metadata("design:paramtypes", [String, String]),
|
|
232
|
+
__metadata("design:returntype", Promise)
|
|
233
|
+
], PipelineController.prototype, "runPipeline", null);
|
|
234
|
+
__decorate([
|
|
235
|
+
(0, common_1.Post)(":projectId/stop"),
|
|
236
|
+
(0, roles_decorator_1.Roles)(client_1.Role.ADMIN),
|
|
237
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
238
|
+
__param(1, (0, current_user_decorator_1.CurrentUser)("id")),
|
|
239
|
+
__metadata("design:type", Function),
|
|
240
|
+
__metadata("design:paramtypes", [String, String]),
|
|
241
|
+
__metadata("design:returntype", Promise)
|
|
242
|
+
], PipelineController.prototype, "stopPipeline", null);
|
|
243
|
+
__decorate([
|
|
244
|
+
(0, common_1.Post)(":projectId/direction"),
|
|
245
|
+
(0, roles_decorator_1.Roles)(client_1.Role.ADMIN),
|
|
246
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
247
|
+
__param(1, (0, common_1.Body)("direction")),
|
|
248
|
+
__param(2, (0, current_user_decorator_1.CurrentUser)("id")),
|
|
249
|
+
__metadata("design:type", Function),
|
|
250
|
+
__metadata("design:paramtypes", [String, String, String]),
|
|
251
|
+
__metadata("design:returntype", Promise)
|
|
252
|
+
], PipelineController.prototype, "sendDirection", null);
|
|
253
|
+
__decorate([
|
|
254
|
+
(0, common_1.Post)(":projectId/sync"),
|
|
255
|
+
(0, roles_decorator_1.Roles)(client_1.Role.ADMIN),
|
|
256
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
257
|
+
__metadata("design:type", Function),
|
|
258
|
+
__metadata("design:paramtypes", [String]),
|
|
259
|
+
__metadata("design:returntype", Promise)
|
|
260
|
+
], PipelineController.prototype, "syncPipelineState", null);
|
|
261
|
+
__decorate([
|
|
262
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
263
|
+
(0, common_1.Get)(":projectId/sessions"),
|
|
264
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
265
|
+
__metadata("design:type", Function),
|
|
266
|
+
__metadata("design:paramtypes", [String]),
|
|
267
|
+
__metadata("design:returntype", Promise)
|
|
268
|
+
], PipelineController.prototype, "getActiveSessions", null);
|
|
269
|
+
__decorate([
|
|
270
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
271
|
+
(0, common_1.Get)(":projectId/sessions/:sessionId"),
|
|
272
|
+
__param(0, (0, common_1.Param)("sessionId", common_1.ParseUUIDPipe)),
|
|
273
|
+
__metadata("design:type", Function),
|
|
274
|
+
__metadata("design:paramtypes", [String]),
|
|
275
|
+
__metadata("design:returntype", Promise)
|
|
276
|
+
], PipelineController.prototype, "getSession", null);
|
|
277
|
+
__decorate([
|
|
278
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
279
|
+
(0, common_1.Get)(":projectId/sessions/:sessionId/diff"),
|
|
280
|
+
__param(0, (0, common_1.Param)("sessionId", common_1.ParseUUIDPipe)),
|
|
281
|
+
__metadata("design:type", Function),
|
|
282
|
+
__metadata("design:paramtypes", [String]),
|
|
283
|
+
__metadata("design:returntype", Promise)
|
|
284
|
+
], PipelineController.prototype, "getSessionDiff", null);
|
|
285
|
+
__decorate([
|
|
286
|
+
(0, throttler_1.SkipThrottle)({ global: true }),
|
|
287
|
+
(0, common_1.Get)(":projectId/sessions/:sessionId/summary"),
|
|
288
|
+
__param(0, (0, common_1.Param)("sessionId", common_1.ParseUUIDPipe)),
|
|
289
|
+
__metadata("design:type", Function),
|
|
290
|
+
__metadata("design:paramtypes", [String]),
|
|
291
|
+
__metadata("design:returntype", Promise)
|
|
292
|
+
], PipelineController.prototype, "getSessionSummary", null);
|
|
293
|
+
__decorate([
|
|
294
|
+
(0, common_1.Post)(":projectId/command"),
|
|
295
|
+
(0, roles_decorator_1.Roles)(client_1.Role.ADMIN),
|
|
296
|
+
__param(0, (0, common_1.Param)("projectId", common_1.ParseUUIDPipe)),
|
|
297
|
+
__param(1, (0, common_1.Body)("prompt")),
|
|
298
|
+
__param(2, (0, current_user_decorator_1.CurrentUser)("id")),
|
|
299
|
+
__metadata("design:type", Function),
|
|
300
|
+
__metadata("design:paramtypes", [String, String, String]),
|
|
301
|
+
__metadata("design:returntype", Promise)
|
|
302
|
+
], PipelineController.prototype, "runCommand", null);
|
|
303
|
+
exports.PipelineController = PipelineController = PipelineController_1 = __decorate([
|
|
304
|
+
(0, common_1.Controller)("pipeline"),
|
|
305
|
+
__metadata("design:paramtypes", [pipeline_service_1.PipelineService,
|
|
306
|
+
pipeline_queue_service_1.PipelineQueueService,
|
|
307
|
+
event_bus_service_1.EventBusService,
|
|
308
|
+
jwt_1.JwtService,
|
|
309
|
+
config_1.ConfigService])
|
|
310
|
+
], PipelineController);
|