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.
- package/dist/docker.d.ts +1 -1
- package/dist/docker.js +65 -57
- 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,706 @@
|
|
|
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 PipelineService_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.PipelineService = void 0;
|
|
17
|
+
const common_1 = require("@nestjs/common");
|
|
18
|
+
const child_process_1 = require("child_process");
|
|
19
|
+
const promises_1 = require("fs/promises");
|
|
20
|
+
const path_1 = require("path");
|
|
21
|
+
const prisma_service_1 = require("../../prisma/prisma.service");
|
|
22
|
+
const activity_log_service_1 = require("../../common/services/activity-log.service");
|
|
23
|
+
const event_bus_service_1 = require("../../common/services/event-bus.service");
|
|
24
|
+
const common_2 = require("@nestjs/common");
|
|
25
|
+
const pipeline_orchestrator_1 = require("./pipeline-orchestrator");
|
|
26
|
+
const pipeline_queue_service_1 = require("./pipeline-queue.service");
|
|
27
|
+
const pipeline_helpers_service_1 = require("./pipeline-helpers.service");
|
|
28
|
+
const pipeline_cto_service_1 = require("./pipeline-cto.service");
|
|
29
|
+
const pipeline_techlead_service_1 = require("./pipeline-techlead.service");
|
|
30
|
+
const pipeline_worker_service_1 = require("./pipeline-worker.service");
|
|
31
|
+
const worker_dispatch_service_1 = require("../worker-api/worker-dispatch.service");
|
|
32
|
+
/** Max age for cached project context before it is regenerated. */
|
|
33
|
+
const CONTEXT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
34
|
+
let PipelineService = PipelineService_1 = class PipelineService {
|
|
35
|
+
prisma;
|
|
36
|
+
activityLog;
|
|
37
|
+
eventBus;
|
|
38
|
+
data;
|
|
39
|
+
pipelineQueue;
|
|
40
|
+
helpers;
|
|
41
|
+
ctoService;
|
|
42
|
+
techLeadService;
|
|
43
|
+
workerService;
|
|
44
|
+
workerDispatch;
|
|
45
|
+
logger = new common_1.Logger(PipelineService_1.name);
|
|
46
|
+
activeProcesses = new Map();
|
|
47
|
+
/** Set of session IDs with a pending stop request. */
|
|
48
|
+
stopRequested = new Set();
|
|
49
|
+
/** Track which projects have an active autopilot loop (projectId → running). */
|
|
50
|
+
autopilotLoops = new Map();
|
|
51
|
+
/** System user ID used for autopilot and other non-user-initiated sessions. */
|
|
52
|
+
systemUserId = "";
|
|
53
|
+
constructor(prisma, activityLog, eventBus, data, pipelineQueue, helpers, ctoService, techLeadService, workerService, workerDispatch) {
|
|
54
|
+
this.prisma = prisma;
|
|
55
|
+
this.activityLog = activityLog;
|
|
56
|
+
this.eventBus = eventBus;
|
|
57
|
+
this.data = data;
|
|
58
|
+
this.pipelineQueue = pipelineQueue;
|
|
59
|
+
this.helpers = helpers;
|
|
60
|
+
this.ctoService = ctoService;
|
|
61
|
+
this.techLeadService = techLeadService;
|
|
62
|
+
this.workerService = workerService;
|
|
63
|
+
this.workerDispatch = workerDispatch;
|
|
64
|
+
}
|
|
65
|
+
async onModuleInit() {
|
|
66
|
+
// Upsert a system user for autopilot and other non-user-initiated sessions
|
|
67
|
+
const systemUser = await this.prisma.user.upsert({
|
|
68
|
+
where: { email: "system@fission.internal" },
|
|
69
|
+
update: {},
|
|
70
|
+
create: {
|
|
71
|
+
email: "system@fission.internal",
|
|
72
|
+
name: "System",
|
|
73
|
+
passwordHash: "not-a-real-hash",
|
|
74
|
+
role: "ADMIN",
|
|
75
|
+
isActive: false, // can't login
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
this.systemUserId = systemUser.id;
|
|
79
|
+
// Cleanup orphaned sessions from previous crashes/restarts
|
|
80
|
+
const orphaned = await this.prisma.session.updateMany({
|
|
81
|
+
where: { status: { in: ["ACTIVE", "QUEUED"] } },
|
|
82
|
+
data: { status: "TERMINATED", endedAt: new Date() },
|
|
83
|
+
});
|
|
84
|
+
if (orphaned.count > 0) {
|
|
85
|
+
this.logger.warn(`Cleaned up ${orphaned.count} orphaned session(s) from previous run`);
|
|
86
|
+
}
|
|
87
|
+
// Cleanup stuck tasks — send IN_PROGRESS back to READY_TO_START
|
|
88
|
+
const stuckInProgress = await this.prisma.task.updateMany({
|
|
89
|
+
where: { status: "IN_PROGRESS" },
|
|
90
|
+
data: { status: "READY_TO_START" },
|
|
91
|
+
});
|
|
92
|
+
if (stuckInProgress.count > 0) {
|
|
93
|
+
this.logger.warn(`Sent ${stuckInProgress.count} stuck IN_PROGRESS task(s) back to READY_TO_START`);
|
|
94
|
+
}
|
|
95
|
+
// Move any legacy PLANNING tasks to READY_TO_START (PLANNING is now a finding status)
|
|
96
|
+
const stuckPlanning = await this.prisma.task.updateMany({
|
|
97
|
+
where: { status: "PLANNED" },
|
|
98
|
+
data: { status: "READY_TO_START" },
|
|
99
|
+
});
|
|
100
|
+
if (stuckPlanning.count > 0) {
|
|
101
|
+
this.logger.warn(`Moved ${stuckPlanning.count} PLANNED task(s) to READY_TO_START`);
|
|
102
|
+
}
|
|
103
|
+
// Register the callback the queue uses to start the next pipeline
|
|
104
|
+
this.pipelineQueue.onStartNext((entry) => {
|
|
105
|
+
this.startPipelineExecution(entry.sessionId, entry.projectId, entry.repoPath, entry.userId).catch((err) => this.logger.error(`Pipeline ${entry.sessionId} unexpected error: ${err}`));
|
|
106
|
+
});
|
|
107
|
+
// Start worker health check (marks stale workers offline, re-queues orphaned sessions)
|
|
108
|
+
setInterval(() => {
|
|
109
|
+
this.workerDispatch.checkWorkerHealth().catch((err) => this.logger.warn(`Worker health check failed: ${err}`));
|
|
110
|
+
}, 60_000);
|
|
111
|
+
// Resume autopilot for projects that had it enabled before restart
|
|
112
|
+
const autopilotProjects = await this.prisma.project.findMany({
|
|
113
|
+
where: { settings: { path: ["autopilot"], equals: true } },
|
|
114
|
+
});
|
|
115
|
+
for (const project of autopilotProjects) {
|
|
116
|
+
const repos = await this.prisma.repository.findMany({ where: { projectId: project.id } });
|
|
117
|
+
if (repos.length > 0) {
|
|
118
|
+
this.logger.log(`[Autopilot] Resuming autopilot for project ${project.id} (${project.name})`);
|
|
119
|
+
this.startAutopilot(project.id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async onApplicationShutdown(signal) {
|
|
124
|
+
const activeCount = this.activeProcesses.size;
|
|
125
|
+
if (activeCount === 0) {
|
|
126
|
+
this.logger.log('No active pipelines — shutting down immediately');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.logger.log(`Received ${signal ?? 'shutdown signal'} — waiting for ${activeCount} active pipeline(s) to complete before shutdown...`);
|
|
130
|
+
// Wait for all active processes to finish (no timeout — always wait)
|
|
131
|
+
await Promise.all(Array.from(this.activeProcesses.entries()).map(([sessionId, proc]) => {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
if (proc.exitCode !== null) {
|
|
134
|
+
resolve();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
proc.on('close', () => {
|
|
138
|
+
this.logger.log(`Pipeline ${sessionId} completed during shutdown`);
|
|
139
|
+
resolve();
|
|
140
|
+
});
|
|
141
|
+
proc.on('error', () => resolve());
|
|
142
|
+
});
|
|
143
|
+
}));
|
|
144
|
+
this.logger.log('All pipelines completed — shutdown proceeding');
|
|
145
|
+
}
|
|
146
|
+
// ------------------------------------------------------------------ //
|
|
147
|
+
// Pipeline history //
|
|
148
|
+
// ------------------------------------------------------------------ //
|
|
149
|
+
async getPipelineHistory(params) {
|
|
150
|
+
const page = Math.max(1, params.page ?? 1);
|
|
151
|
+
const limit = Math.min(100, Math.max(1, params.limit ?? 20));
|
|
152
|
+
const skip = (page - 1) * limit;
|
|
153
|
+
const where = {};
|
|
154
|
+
if (params.projectId)
|
|
155
|
+
where.projectId = params.projectId;
|
|
156
|
+
const [sessions, total] = await Promise.all([
|
|
157
|
+
this.prisma.session.findMany({
|
|
158
|
+
where,
|
|
159
|
+
orderBy: { startedAt: "desc" },
|
|
160
|
+
skip,
|
|
161
|
+
take: limit,
|
|
162
|
+
include: {
|
|
163
|
+
project: { select: { id: true, name: true } },
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
this.prisma.session.count({ where }),
|
|
167
|
+
]);
|
|
168
|
+
// For each session, count findings and tasks created during its lifetime
|
|
169
|
+
const data = await Promise.all(sessions.map(async (session) => {
|
|
170
|
+
const phaseCompleteLogs = await this.prisma.activityLog.findMany({
|
|
171
|
+
where: {
|
|
172
|
+
entityType: "Session",
|
|
173
|
+
entityId: session.id,
|
|
174
|
+
action: "PIPELINE_PHASE_COMPLETE",
|
|
175
|
+
},
|
|
176
|
+
select: { details: true },
|
|
177
|
+
});
|
|
178
|
+
let findingsCount = 0;
|
|
179
|
+
let tasksCount = 0;
|
|
180
|
+
for (const log of phaseCompleteLogs) {
|
|
181
|
+
const details = log.details;
|
|
182
|
+
if (details) {
|
|
183
|
+
if (typeof details.findingsCreated === "number") {
|
|
184
|
+
findingsCount += details.findingsCreated;
|
|
185
|
+
}
|
|
186
|
+
if (typeof details.tasksCreated === "number") {
|
|
187
|
+
tasksCount += details.tasksCreated;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
...session,
|
|
193
|
+
findingsCreated: findingsCount,
|
|
194
|
+
tasksCreated: tasksCount,
|
|
195
|
+
};
|
|
196
|
+
}));
|
|
197
|
+
return {
|
|
198
|
+
data,
|
|
199
|
+
meta: {
|
|
200
|
+
total,
|
|
201
|
+
page,
|
|
202
|
+
limit,
|
|
203
|
+
totalPages: Math.ceil(total / limit),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// ------------------------------------------------------------------ //
|
|
208
|
+
// Run an arbitrary claude -p command //
|
|
209
|
+
// ------------------------------------------------------------------ //
|
|
210
|
+
async runCommand(projectId, prompt, userId) {
|
|
211
|
+
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
|
212
|
+
if (!project)
|
|
213
|
+
throw new common_1.NotFoundException("Project not found");
|
|
214
|
+
const repos = await this.prisma.repository.findMany({ where: { projectId } });
|
|
215
|
+
const cwd = repos.length > 0 ? repos[0].repoPath : process.cwd();
|
|
216
|
+
const { stdout, stderr, exitCode } = await this.helpers.spawnClaude(prompt, cwd);
|
|
217
|
+
const output = stdout + (stderr ? `\n[stderr]\n${stderr}` : "");
|
|
218
|
+
await this.activityLog.log("PIPELINE_COMMAND", "Project", projectId, userId, { prompt, exitCode, outputLength: output.length });
|
|
219
|
+
return { output, exitCode };
|
|
220
|
+
}
|
|
221
|
+
// ------------------------------------------------------------------ //
|
|
222
|
+
// Run the full R&D pipeline (background — returns immediately) //
|
|
223
|
+
// ------------------------------------------------------------------ //
|
|
224
|
+
async runPipeline(projectId, userId) {
|
|
225
|
+
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
|
226
|
+
if (!project)
|
|
227
|
+
throw new common_1.NotFoundException("Project not found");
|
|
228
|
+
const repos = await this.prisma.repository.findMany({ where: { projectId } });
|
|
229
|
+
const primaryRepoPath = repos.length > 0 ? repos[0].repoPath : process.cwd();
|
|
230
|
+
// Create the session with QUEUED status initially
|
|
231
|
+
const session = await this.prisma.session.create({
|
|
232
|
+
data: {
|
|
233
|
+
projectId,
|
|
234
|
+
userId: userId ?? this.systemUserId,
|
|
235
|
+
status: "QUEUED",
|
|
236
|
+
phase: "QUEUED",
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
await this.activityLog.log("PIPELINE_START", "Session", session.id, userId, { projectId });
|
|
240
|
+
// Try to dispatch to a remote worker first
|
|
241
|
+
const worker = await this.workerDispatch.findWorker(projectId, userId ?? this.systemUserId);
|
|
242
|
+
if (worker) {
|
|
243
|
+
await this.prisma.session.update({
|
|
244
|
+
where: { id: session.id },
|
|
245
|
+
data: { status: "DISPATCHED", workerId: worker.id },
|
|
246
|
+
});
|
|
247
|
+
await this.prisma.worker.update({
|
|
248
|
+
where: { id: worker.id },
|
|
249
|
+
data: { activeSessions: { increment: 1 } },
|
|
250
|
+
});
|
|
251
|
+
this.logger.log(`[${session.id}] Dispatched to worker "${worker.name}" (${worker.id.slice(0, 8)})`);
|
|
252
|
+
return { sessionId: session.id, message: `Dispatched to worker "${worker.name}"`, position: 0 };
|
|
253
|
+
}
|
|
254
|
+
// No worker available — fall back to local execution via queue
|
|
255
|
+
const { position } = this.pipelineQueue.enqueue({
|
|
256
|
+
sessionId: session.id,
|
|
257
|
+
projectId: project.id,
|
|
258
|
+
userId,
|
|
259
|
+
repoPath: primaryRepoPath,
|
|
260
|
+
});
|
|
261
|
+
if (position === 0) {
|
|
262
|
+
return { sessionId: session.id, message: "Pipeline started (local)", position: 0 };
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
sessionId: session.id,
|
|
266
|
+
message: `Queued at position ${position}`,
|
|
267
|
+
position,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Called by the queue when it's this session's turn to execute.
|
|
272
|
+
* Promotes the session from QUEUED to ACTIVE, then runs parallel agents.
|
|
273
|
+
*/
|
|
274
|
+
async startPipelineExecution(sessionId, projectId, repoPath, userId) {
|
|
275
|
+
await this.data.updateSession(sessionId, { status: "ACTIVE", phase: "REVIEW" });
|
|
276
|
+
await this.runParallelAgents(sessionId, projectId, repoPath, userId);
|
|
277
|
+
}
|
|
278
|
+
// ------------------------------------------------------------------ //
|
|
279
|
+
// Parallel agent orchestrator (runs in background) //
|
|
280
|
+
// ------------------------------------------------------------------ //
|
|
281
|
+
async runParallelAgents(sessionId, projectId, repoPath, userId) {
|
|
282
|
+
const repos = await this.prisma.repository.findMany({ where: { projectId } });
|
|
283
|
+
const repoPaths = repos.length > 0
|
|
284
|
+
? repos.map((r) => `${r.name}: ${r.repoPath}`).join("\n")
|
|
285
|
+
: `Default: ${repoPath}`;
|
|
286
|
+
const repoList = repos.length > 0
|
|
287
|
+
? repos.map((r) => ({ name: r.name, repoPath: r.repoPath }))
|
|
288
|
+
: [{ name: "default", repoPath }];
|
|
289
|
+
const projectContext = await this.getOrCreateProjectContext(projectId, repoList);
|
|
290
|
+
const stopSignal = { stopped: false };
|
|
291
|
+
// Wire up stop detection
|
|
292
|
+
const checkStop = setInterval(() => {
|
|
293
|
+
if (this.stopRequested.has(sessionId))
|
|
294
|
+
stopSignal.stopped = true;
|
|
295
|
+
}, 1000);
|
|
296
|
+
try {
|
|
297
|
+
await (0, pipeline_orchestrator_1.runPipelineLoop)({
|
|
298
|
+
data: this.data,
|
|
299
|
+
helpers: this.helpers,
|
|
300
|
+
cto: this.ctoService,
|
|
301
|
+
techLead: this.techLeadService,
|
|
302
|
+
worker: this.workerService,
|
|
303
|
+
sessionId,
|
|
304
|
+
projectId,
|
|
305
|
+
repoPath,
|
|
306
|
+
repoPaths,
|
|
307
|
+
repoList,
|
|
308
|
+
projectContext,
|
|
309
|
+
userId,
|
|
310
|
+
stopSignal,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
clearInterval(checkStop);
|
|
315
|
+
this.activeProcesses.delete(sessionId);
|
|
316
|
+
this.stopRequested.delete(sessionId);
|
|
317
|
+
this.pipelineQueue.dequeue();
|
|
318
|
+
// Invalidate cached project context so next pipeline run gets fresh data
|
|
319
|
+
await this.prisma.project.update({
|
|
320
|
+
where: { id: projectId },
|
|
321
|
+
data: {
|
|
322
|
+
settings: {
|
|
323
|
+
...((await this.prisma.project.findUnique({ where: { id: projectId } }))?.settings || {}),
|
|
324
|
+
context: null,
|
|
325
|
+
contextGeneratedAt: null,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
}).catch((err) => this.logger.warn(`Failed to invalidate project context: ${err}`));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// ------------------------------------------------------------------ //
|
|
332
|
+
// Stop a running pipeline //
|
|
333
|
+
// ------------------------------------------------------------------ //
|
|
334
|
+
async stopPipeline(projectId, userId) {
|
|
335
|
+
const sessions = await this.prisma.session.findMany({
|
|
336
|
+
where: { projectId, status: { in: ["ACTIVE", "QUEUED", "DISPATCHED"] } },
|
|
337
|
+
});
|
|
338
|
+
let killed = 0;
|
|
339
|
+
let dequeued = 0;
|
|
340
|
+
for (const session of sessions) {
|
|
341
|
+
if (session.status === "QUEUED") {
|
|
342
|
+
this.pipelineQueue.cancel(session.id);
|
|
343
|
+
dequeued++;
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
this.stopRequested.add(session.id);
|
|
347
|
+
const proc = this.activeProcesses.get(session.id);
|
|
348
|
+
if (proc) {
|
|
349
|
+
proc.kill("SIGTERM");
|
|
350
|
+
this.activeProcesses.delete(session.id);
|
|
351
|
+
killed++;
|
|
352
|
+
}
|
|
353
|
+
this.pipelineQueue.cancel(session.id);
|
|
354
|
+
}
|
|
355
|
+
await this.prisma.session.update({
|
|
356
|
+
where: { id: session.id },
|
|
357
|
+
data: { status: "TERMINATED", endedAt: new Date() },
|
|
358
|
+
});
|
|
359
|
+
await this.activityLog.log("PIPELINE_STOP", "Session", session.id, userId, { reason: "user_requested" });
|
|
360
|
+
}
|
|
361
|
+
// Clear stale worker slot tracking and persistent sessions
|
|
362
|
+
this.helpers.workerTasks.clear();
|
|
363
|
+
this.helpers.slot3Info = null;
|
|
364
|
+
this.workerService.clearSessions();
|
|
365
|
+
return { message: `Stopped ${killed} process(es), removed ${dequeued} from queue` };
|
|
366
|
+
}
|
|
367
|
+
// ------------------------------------------------------------------ //
|
|
368
|
+
// Autopilot — run pipeline in a continuous loop //
|
|
369
|
+
// ------------------------------------------------------------------ //
|
|
370
|
+
async setAutopilot(projectId, enabled) {
|
|
371
|
+
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
|
372
|
+
if (!project)
|
|
373
|
+
throw new common_1.NotFoundException("Project not found");
|
|
374
|
+
await this.prisma.project.update({
|
|
375
|
+
where: { id: projectId },
|
|
376
|
+
data: {
|
|
377
|
+
settings: {
|
|
378
|
+
...(project?.settings || {}),
|
|
379
|
+
autopilot: enabled,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
if (enabled) {
|
|
384
|
+
this.startAutopilot(projectId);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
this.autopilotLoops.set(projectId, false);
|
|
388
|
+
await this.stopPipeline(projectId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async isAutopilotEnabled(projectId) {
|
|
392
|
+
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
|
393
|
+
return !!project?.settings?.autopilot;
|
|
394
|
+
}
|
|
395
|
+
async startAutopilot(projectId) {
|
|
396
|
+
if (this.autopilotLoops.get(projectId))
|
|
397
|
+
return; // already running
|
|
398
|
+
this.autopilotLoops.set(projectId, true);
|
|
399
|
+
let consecutiveFailures = 0;
|
|
400
|
+
const MAX_FAILURES = 3;
|
|
401
|
+
const COOLDOWN_MS = 30_000;
|
|
402
|
+
this.logger.log(`[Autopilot] Started for project ${projectId}`);
|
|
403
|
+
while (this.autopilotLoops.get(projectId)) {
|
|
404
|
+
try {
|
|
405
|
+
const enabled = await this.isAutopilotEnabled(projectId);
|
|
406
|
+
if (!enabled)
|
|
407
|
+
break;
|
|
408
|
+
const result = await this.runPipeline(projectId, this.systemUserId);
|
|
409
|
+
await this.waitForSessionComplete(result.sessionId);
|
|
410
|
+
const workDone = await this.prisma.activityLog.count({
|
|
411
|
+
where: {
|
|
412
|
+
entityId: result.sessionId,
|
|
413
|
+
action: { in: ["WORK_COMPLETED", "PIPELINE_FINISH"] },
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
if (workDone > 0) {
|
|
417
|
+
consecutiveFailures = 0;
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
consecutiveFailures++;
|
|
421
|
+
this.logger.warn(`[Autopilot] No work done in cycle (failures: ${consecutiveFailures}/${MAX_FAILURES})`);
|
|
422
|
+
}
|
|
423
|
+
if (consecutiveFailures >= MAX_FAILURES) {
|
|
424
|
+
this.logger.warn(`[Autopilot] Too many consecutive empty cycles, disabling for project ${projectId}`);
|
|
425
|
+
await this.setAutopilot(projectId, false);
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
this.logger.log(`[Autopilot] Cycle complete, cooling down ${COOLDOWN_MS / 1000}s...`);
|
|
429
|
+
await new Promise((r) => setTimeout(r, COOLDOWN_MS));
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
this.logger.error(`[Autopilot] Error: ${err}`);
|
|
433
|
+
consecutiveFailures++;
|
|
434
|
+
if (consecutiveFailures >= MAX_FAILURES) {
|
|
435
|
+
this.logger.warn(`[Autopilot] Too many errors, disabling for project ${projectId}`);
|
|
436
|
+
await this.setAutopilot(projectId, false);
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
await new Promise((r) => setTimeout(r, COOLDOWN_MS));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this.autopilotLoops.delete(projectId);
|
|
443
|
+
this.logger.log(`[Autopilot] Stopped for project ${projectId}`);
|
|
444
|
+
}
|
|
445
|
+
async waitForSessionComplete(sessionId) {
|
|
446
|
+
while (true) {
|
|
447
|
+
const session = await this.prisma.session.findUnique({ where: { id: sessionId } });
|
|
448
|
+
if (!session || session.status === "TERMINATED")
|
|
449
|
+
return;
|
|
450
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// ------------------------------------------------------------------ //
|
|
454
|
+
// Send a user direction / instruction to the pipeline //
|
|
455
|
+
// ------------------------------------------------------------------ //
|
|
456
|
+
async sendDirection(projectId, direction, userId) {
|
|
457
|
+
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
|
458
|
+
if (!project)
|
|
459
|
+
throw new common_1.NotFoundException("Project not found");
|
|
460
|
+
// Escape HTML/script tags in user input to prevent XSS when rendered
|
|
461
|
+
const escapedDirection = direction
|
|
462
|
+
.replace(/</g, "<")
|
|
463
|
+
.replace(/>/g, ">");
|
|
464
|
+
const finding = await this.prisma.finding.create({
|
|
465
|
+
data: {
|
|
466
|
+
projectId,
|
|
467
|
+
title: escapedDirection.length > 100 ? escapedDirection.slice(0, 100) + "..." : escapedDirection,
|
|
468
|
+
description: escapedDirection,
|
|
469
|
+
severity: "MEDIUM",
|
|
470
|
+
category: "FEATURE",
|
|
471
|
+
status: "NEW",
|
|
472
|
+
source: "user-direction",
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
await this.activityLog.log("PIPELINE_DIRECTION", "Finding", finding.id, userId, { direction });
|
|
476
|
+
return { findingId: finding.id, message: "Added to backlog" };
|
|
477
|
+
}
|
|
478
|
+
// ------------------------------------------------------------------ //
|
|
479
|
+
// Get active pipeline sessions //
|
|
480
|
+
// ------------------------------------------------------------------ //
|
|
481
|
+
async getActiveSessions(projectId) {
|
|
482
|
+
const where = { status: { in: ["ACTIVE", "QUEUED", "DISPATCHED"] } };
|
|
483
|
+
if (projectId)
|
|
484
|
+
where.projectId = projectId;
|
|
485
|
+
const sessions = await this.prisma.session.findMany({
|
|
486
|
+
where,
|
|
487
|
+
orderBy: { startedAt: "desc" },
|
|
488
|
+
});
|
|
489
|
+
return { data: sessions };
|
|
490
|
+
}
|
|
491
|
+
// ------------------------------------------------------------------ //
|
|
492
|
+
// Sync docs/rnd/ files to DB (findings + tasks) //
|
|
493
|
+
// ------------------------------------------------------------------ //
|
|
494
|
+
async syncPipelineState(projectId) {
|
|
495
|
+
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
|
496
|
+
if (!project)
|
|
497
|
+
throw new common_1.NotFoundException("Project not found");
|
|
498
|
+
const repos = await this.prisma.repository.findMany({ where: { projectId } });
|
|
499
|
+
const primaryRepoPath = repos.length > 0 ? repos[0].repoPath : process.cwd();
|
|
500
|
+
const rndRoot = (0, path_1.join)(primaryRepoPath, "docs", "rnd");
|
|
501
|
+
let findingsUpserted = 0;
|
|
502
|
+
let tasksUpserted = 0;
|
|
503
|
+
// ---- Sync backlog findings ----
|
|
504
|
+
const backlogDir = (0, path_1.join)(rndRoot, "backlog");
|
|
505
|
+
const backlogFiles = await this.helpers.safeReadDir(backlogDir);
|
|
506
|
+
for (const file of backlogFiles) {
|
|
507
|
+
if (!file.endsWith(".md"))
|
|
508
|
+
continue;
|
|
509
|
+
try {
|
|
510
|
+
const content = await (0, promises_1.readFile)((0, path_1.join)(backlogDir, file), "utf-8");
|
|
511
|
+
const meta = this.helpers.parseFrontmatter(content);
|
|
512
|
+
if (!meta.id)
|
|
513
|
+
continue;
|
|
514
|
+
await this.prisma.finding.upsert({
|
|
515
|
+
where: { projectId_externalId: { projectId, externalId: meta.id } },
|
|
516
|
+
create: {
|
|
517
|
+
projectId,
|
|
518
|
+
externalId: meta.id,
|
|
519
|
+
title: meta.title || file.replace(/\.md$/, ""),
|
|
520
|
+
description: this.helpers.extractBody(content),
|
|
521
|
+
severity: this.helpers.toFindingSeverity(meta.severity),
|
|
522
|
+
category: this.helpers.toFindingCategory(meta.category),
|
|
523
|
+
status: this.helpers.toFindingStatus(meta.status),
|
|
524
|
+
source: "pipeline-sync",
|
|
525
|
+
},
|
|
526
|
+
update: {
|
|
527
|
+
title: meta.title || undefined,
|
|
528
|
+
description: this.helpers.extractBody(content) || undefined,
|
|
529
|
+
severity: this.helpers.toFindingSeverity(meta.severity),
|
|
530
|
+
category: this.helpers.toFindingCategory(meta.category),
|
|
531
|
+
status: this.helpers.toFindingStatus(meta.status),
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
findingsUpserted++;
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
this.logger.warn(`Failed to sync finding from ${file}: ${error}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// ---- Sync tasks (active + completed) ----
|
|
541
|
+
for (const subdir of ["tasks", "completed"]) {
|
|
542
|
+
const tasksDir = (0, path_1.join)(rndRoot, subdir);
|
|
543
|
+
const taskFiles = await this.helpers.safeReadDir(tasksDir);
|
|
544
|
+
for (const file of taskFiles) {
|
|
545
|
+
if (!file.endsWith(".md"))
|
|
546
|
+
continue;
|
|
547
|
+
try {
|
|
548
|
+
const content = await (0, promises_1.readFile)((0, path_1.join)(tasksDir, file), "utf-8");
|
|
549
|
+
const meta = this.helpers.parseFrontmatter(content);
|
|
550
|
+
if (!meta.id)
|
|
551
|
+
continue;
|
|
552
|
+
await this.prisma.task.upsert({
|
|
553
|
+
where: { projectId_externalId: { projectId, externalId: meta.id } },
|
|
554
|
+
create: {
|
|
555
|
+
projectId,
|
|
556
|
+
externalId: meta.id,
|
|
557
|
+
title: meta.title || file.replace(/\.md$/, ""),
|
|
558
|
+
objective: this.helpers.extractBody(content),
|
|
559
|
+
priority: this.helpers.toTaskPriority(meta.priority),
|
|
560
|
+
status: subdir === "completed" ? "COMPLETED" : this.helpers.toTaskStatus(meta.status),
|
|
561
|
+
effort: meta.effort || null,
|
|
562
|
+
},
|
|
563
|
+
update: {
|
|
564
|
+
title: meta.title || undefined,
|
|
565
|
+
objective: this.helpers.extractBody(content) || undefined,
|
|
566
|
+
priority: this.helpers.toTaskPriority(meta.priority),
|
|
567
|
+
status: subdir === "completed" ? "COMPLETED" : this.helpers.toTaskStatus(meta.status),
|
|
568
|
+
effort: meta.effort || undefined,
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
tasksUpserted++;
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
this.logger.warn(`Failed to sync task from ${subdir}/${file}: ${error}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return { findingsUpserted, tasksUpserted };
|
|
579
|
+
}
|
|
580
|
+
// ------------------------------------------------------------------ //
|
|
581
|
+
// Session detail endpoints //
|
|
582
|
+
// ------------------------------------------------------------------ //
|
|
583
|
+
async getSession(sessionId) {
|
|
584
|
+
const session = await this.prisma.session.findUnique({
|
|
585
|
+
where: { id: sessionId },
|
|
586
|
+
});
|
|
587
|
+
if (!session)
|
|
588
|
+
throw new common_1.NotFoundException("Session not found");
|
|
589
|
+
return { data: session };
|
|
590
|
+
}
|
|
591
|
+
async getSessionDiff(sessionId) {
|
|
592
|
+
const session = await this.prisma.session.findUnique({
|
|
593
|
+
where: { id: sessionId },
|
|
594
|
+
});
|
|
595
|
+
if (!session)
|
|
596
|
+
throw new common_1.NotFoundException("Session not found");
|
|
597
|
+
const metadata = session.metadata;
|
|
598
|
+
return {
|
|
599
|
+
diff: metadata?.diff ?? null,
|
|
600
|
+
diffStat: metadata?.diffStat ?? null,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
async getSessionSummary(sessionId) {
|
|
604
|
+
const session = await this.prisma.session.findUnique({
|
|
605
|
+
where: { id: sessionId },
|
|
606
|
+
});
|
|
607
|
+
if (!session)
|
|
608
|
+
throw new common_1.NotFoundException("Session not found");
|
|
609
|
+
const metadata = session.metadata;
|
|
610
|
+
return metadata?.summary ?? null;
|
|
611
|
+
}
|
|
612
|
+
// ------------------------------------------------------------------ //
|
|
613
|
+
// Agent state //
|
|
614
|
+
// ------------------------------------------------------------------ //
|
|
615
|
+
/** Emit current agent slot state to all SSE listeners for a project. */
|
|
616
|
+
emitAgentState(projectId) {
|
|
617
|
+
this.helpers.emitAgentState(projectId);
|
|
618
|
+
}
|
|
619
|
+
/** Get current agent state from DB — persistent, accurate across refreshes/devices. */
|
|
620
|
+
async getAgentStateFromDb() {
|
|
621
|
+
const inProgressTasks = await this.prisma.task.findMany({
|
|
622
|
+
where: { status: "IN_PROGRESS" },
|
|
623
|
+
orderBy: { updatedAt: "asc" },
|
|
624
|
+
take: 2,
|
|
625
|
+
});
|
|
626
|
+
const planningFindings = await this.prisma.finding.findMany({
|
|
627
|
+
where: { status: "PLANNING" },
|
|
628
|
+
take: 1,
|
|
629
|
+
});
|
|
630
|
+
const reviewSession = await this.prisma.session.findFirst({
|
|
631
|
+
where: { status: "ACTIVE", phase: "REVIEW" },
|
|
632
|
+
});
|
|
633
|
+
return {
|
|
634
|
+
worker1: inProgressTasks[0] ? { taskTitle: inProgressTasks[0].title, status: "building" } : null,
|
|
635
|
+
worker2: inProgressTasks[1] ? { taskTitle: inProgressTasks[1].title, status: "building" } : null,
|
|
636
|
+
slot3: planningFindings.length > 0
|
|
637
|
+
? { role: "Tech Lead", taskTitle: `Planning: ${planningFindings[0].title}`, status: "planning" }
|
|
638
|
+
: reviewSession
|
|
639
|
+
? { role: "CTO", taskTitle: "Reviewing codebase", status: "reviewing" }
|
|
640
|
+
: null,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
// ================================================================== //
|
|
644
|
+
// Private helpers //
|
|
645
|
+
// ================================================================== //
|
|
646
|
+
/**
|
|
647
|
+
* Generate (or retrieve cached) a lightweight project summary from repo
|
|
648
|
+
* structure and dependencies.
|
|
649
|
+
*/
|
|
650
|
+
async getOrCreateProjectContext(projectId, repos) {
|
|
651
|
+
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
|
652
|
+
const settings = project?.settings || {};
|
|
653
|
+
if (settings.context && settings.contextGeneratedAt) {
|
|
654
|
+
const generatedAt = new Date(settings.contextGeneratedAt).getTime();
|
|
655
|
+
const age = Date.now() - generatedAt;
|
|
656
|
+
if (age < CONTEXT_MAX_AGE_MS) {
|
|
657
|
+
return settings.context;
|
|
658
|
+
}
|
|
659
|
+
this.logger.log(`Project context is stale (${Math.round(age / 60_000)}m old) — regenerating`);
|
|
660
|
+
}
|
|
661
|
+
let context = "Project repositories:\n";
|
|
662
|
+
for (const repo of repos) {
|
|
663
|
+
try {
|
|
664
|
+
const packageJson = (0, child_process_1.execSync)("cat package.json 2>/dev/null || echo '{}'", {
|
|
665
|
+
cwd: repo.repoPath,
|
|
666
|
+
encoding: "utf-8",
|
|
667
|
+
});
|
|
668
|
+
const parsed = JSON.parse(packageJson);
|
|
669
|
+
context += `\n${repo.name} (${repo.repoPath}):\n`;
|
|
670
|
+
context += ` Stack: ${Object.keys(parsed.dependencies || {}).slice(0, 10).join(", ")}\n`;
|
|
671
|
+
const structure = (0, child_process_1.execSync)("find . -name '*.ts' -o -name '*.tsx' | head -20", {
|
|
672
|
+
cwd: repo.repoPath,
|
|
673
|
+
encoding: "utf-8",
|
|
674
|
+
});
|
|
675
|
+
context += ` Key files: ${structure.trim()}\n`;
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
/* ignore repos without package.json */
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
await this.prisma.project.update({
|
|
682
|
+
where: { id: projectId },
|
|
683
|
+
data: {
|
|
684
|
+
settings: {
|
|
685
|
+
...(project?.settings || {}),
|
|
686
|
+
context,
|
|
687
|
+
contextGeneratedAt: new Date().toISOString(),
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
return context;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
exports.PipelineService = PipelineService;
|
|
695
|
+
exports.PipelineService = PipelineService = PipelineService_1 = __decorate([
|
|
696
|
+
(0, common_1.Injectable)(),
|
|
697
|
+
__param(3, (0, common_2.Inject)("PIPELINE_DATA")),
|
|
698
|
+
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
|
699
|
+
activity_log_service_1.ActivityLogService,
|
|
700
|
+
event_bus_service_1.EventBusService, Object, pipeline_queue_service_1.PipelineQueueService,
|
|
701
|
+
pipeline_helpers_service_1.PipelineHelpersService,
|
|
702
|
+
pipeline_cto_service_1.PipelineCtoService,
|
|
703
|
+
pipeline_techlead_service_1.PipelineTechLeadService,
|
|
704
|
+
pipeline_worker_service_1.PipelineWorkerService,
|
|
705
|
+
worker_dispatch_service_1.WorkerDispatchService])
|
|
706
|
+
], PipelineService);
|