fission-worker 0.2.1 → 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.
Files changed (30) hide show
  1. package/dist/docker.d.ts +1 -1
  2. package/dist/docker.js +65 -57
  3. package/dist/docker.js.map +1 -1
  4. package/package.json +4 -3
  5. package/runner/common/decorators/current-user.decorator.js +10 -0
  6. package/runner/common/decorators/public.decorator.js +7 -0
  7. package/runner/common/decorators/roles.decorator.js +7 -0
  8. package/runner/common/services/activity-log.service.js +48 -0
  9. package/runner/common/services/event-bus.service.js +38 -0
  10. package/runner/modules/github/github.controller.js +155 -0
  11. package/runner/modules/github/github.module.js +22 -0
  12. package/runner/modules/github/github.service.js +104 -0
  13. package/runner/modules/pipeline/data/api-data.service.js +140 -0
  14. package/runner/modules/pipeline/data/pipeline-data.interface.js +2 -0
  15. package/runner/modules/pipeline/data/prisma-data.service.js +149 -0
  16. package/runner/modules/pipeline/pipeline-cto.service.js +129 -0
  17. package/runner/modules/pipeline/pipeline-helpers.service.js +318 -0
  18. package/runner/modules/pipeline/pipeline-orchestrator.js +399 -0
  19. package/runner/modules/pipeline/pipeline-queue.service.js +121 -0
  20. package/runner/modules/pipeline/pipeline-techlead.service.js +127 -0
  21. package/runner/modules/pipeline/pipeline-worker.service.js +343 -0
  22. package/runner/modules/pipeline/pipeline.controller.js +310 -0
  23. package/runner/modules/pipeline/pipeline.module.js +51 -0
  24. package/runner/modules/pipeline/pipeline.service.js +706 -0
  25. package/runner/modules/worker-api/worker-api.controller.js +497 -0
  26. package/runner/modules/worker-api/worker-api.guard.js +41 -0
  27. package/runner/modules/worker-api/worker-api.module.js +25 -0
  28. package/runner/modules/worker-api/worker-dispatch.service.js +87 -0
  29. package/runner/pipeline-runner/index.js +108 -0
  30. 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);