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,399 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runPipelineLoop = runPipelineLoop;
|
|
37
|
+
const common_1 = require("@nestjs/common");
|
|
38
|
+
const logger = new common_1.Logger("PipelineOrchestrator");
|
|
39
|
+
/**
|
|
40
|
+
* Core pipeline orchestration loop — portable across monolith and worker containers.
|
|
41
|
+
* The caller provides all context (repos, project context, data source).
|
|
42
|
+
* This function runs the checkAndSpawn loop, captures diffs, and generates summaries.
|
|
43
|
+
*/
|
|
44
|
+
async function runPipelineLoop(ctx) {
|
|
45
|
+
const { data, helpers, cto, techLead, worker, sessionId, projectId, repoPath, repoPaths, repoList, projectContext, userId, stopSignal, } = ctx;
|
|
46
|
+
// Capture git HEAD for each repo before any work starts (for diff)
|
|
47
|
+
const preWorkHeads = {};
|
|
48
|
+
const repoDirs = repoList.map((r) => r.repoPath);
|
|
49
|
+
for (const dir of repoDirs) {
|
|
50
|
+
try {
|
|
51
|
+
preWorkHeads[dir] = helpers.gitRevParse(dir);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// not a git repo — ignore
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Clear stale state from previous runs
|
|
58
|
+
helpers.workerTasks.clear();
|
|
59
|
+
helpers.slot3Info = null;
|
|
60
|
+
worker.clearSessions();
|
|
61
|
+
const MAX_WORKERS = 2;
|
|
62
|
+
const MAX_ITERATIONS = 20;
|
|
63
|
+
let iteration = 0;
|
|
64
|
+
let activeWorkers = 0;
|
|
65
|
+
let techLeadActive = false;
|
|
66
|
+
let ctoActive = false;
|
|
67
|
+
// Track running promises
|
|
68
|
+
let runningTasks = [];
|
|
69
|
+
const isStopped = () => stopSignal?.stopped === true;
|
|
70
|
+
const checkAndSpawn = async () => {
|
|
71
|
+
if (isStopped())
|
|
72
|
+
return;
|
|
73
|
+
// ============================================================ //
|
|
74
|
+
// Query all bucket states //
|
|
75
|
+
// ============================================================ //
|
|
76
|
+
const qaQueue = await data.findTasks(projectId, {
|
|
77
|
+
status: "IN_REVIEW",
|
|
78
|
+
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
|
79
|
+
});
|
|
80
|
+
const devQueue = await data.findTasks(projectId, {
|
|
81
|
+
status: "READY_TO_START",
|
|
82
|
+
retryCountLt: 3,
|
|
83
|
+
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
|
84
|
+
});
|
|
85
|
+
const failedTasks = await data.findTasks(projectId, {
|
|
86
|
+
status: "FAILED",
|
|
87
|
+
include: { finding: { select: { id: true } } },
|
|
88
|
+
});
|
|
89
|
+
const plannedQueue = await data.findTasks(projectId, {
|
|
90
|
+
status: "PLANNED",
|
|
91
|
+
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
|
92
|
+
});
|
|
93
|
+
const planningFindings = await data.findFindings(projectId, {
|
|
94
|
+
status: "PLANNING",
|
|
95
|
+
});
|
|
96
|
+
const backlogFindings = await data.findFindings(projectId, {
|
|
97
|
+
status: ["NEW", "TRIAGED"],
|
|
98
|
+
});
|
|
99
|
+
const userDirections = await data.findFindings(projectId, {
|
|
100
|
+
source: "user-direction",
|
|
101
|
+
status: ["NEW", "TRIAGED"],
|
|
102
|
+
});
|
|
103
|
+
// ============================================================ //
|
|
104
|
+
// Emit work state for monitoring //
|
|
105
|
+
// ============================================================ //
|
|
106
|
+
data.emit(projectId, "work_state", {
|
|
107
|
+
qaQueue: qaQueue.length,
|
|
108
|
+
devQueue: devQueue.length,
|
|
109
|
+
failedTasks: failedTasks.length,
|
|
110
|
+
plannedTasks: plannedQueue.length,
|
|
111
|
+
backlogFindings: backlogFindings.length,
|
|
112
|
+
planningFindings: planningFindings.length,
|
|
113
|
+
userDirections: userDirections.length,
|
|
114
|
+
iteration,
|
|
115
|
+
activeWorkers,
|
|
116
|
+
techLeadActive,
|
|
117
|
+
ctoActive,
|
|
118
|
+
agents: {
|
|
119
|
+
worker1: helpers.workerTasks.has(0) ? { taskTitle: helpers.workerTasks.get(0), status: "building" } : null,
|
|
120
|
+
worker2: helpers.workerTasks.has(1) ? { taskTitle: helpers.workerTasks.get(1), status: "building" } : null,
|
|
121
|
+
slot3: helpers.slot3Info ? { role: helpers.slot3Info.role, taskTitle: helpers.slot3Info.task, status: "working" } : null,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
// ============================================================ //
|
|
125
|
+
// Worker bucket 3: Auto-replan FAILED tasks (no slot needed) //
|
|
126
|
+
// ============================================================ //
|
|
127
|
+
if (qaQueue.length === 0 && devQueue.length === 0 && failedTasks.length > 0) {
|
|
128
|
+
for (const task of failedTasks) {
|
|
129
|
+
await worker.replanFailedTask({
|
|
130
|
+
id: task.id,
|
|
131
|
+
title: task.title,
|
|
132
|
+
objective: task.objective,
|
|
133
|
+
projectId: task.projectId,
|
|
134
|
+
findingId: task.findingId,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ============================================================ //
|
|
139
|
+
// Worker slots (0-1): Priority queue //
|
|
140
|
+
// ============================================================ //
|
|
141
|
+
const workQueue = [
|
|
142
|
+
...qaQueue.map((t) => ({ task: t, action: "qa" })),
|
|
143
|
+
...devQueue.map((t) => ({ task: t, action: "build" })),
|
|
144
|
+
...plannedQueue.map((t) => ({ task: t, action: "build" })),
|
|
145
|
+
];
|
|
146
|
+
const maxWorkersNow = Math.min(MAX_WORKERS, 3 - (techLeadActive || ctoActive ? 1 : 0));
|
|
147
|
+
let workerSlot = 0;
|
|
148
|
+
while (helpers.workerTasks.has(workerSlot) && workerSlot < MAX_WORKERS)
|
|
149
|
+
workerSlot++;
|
|
150
|
+
while (activeWorkers < maxWorkersNow && workQueue.length > 0) {
|
|
151
|
+
const { task, action } = workQueue.shift();
|
|
152
|
+
const slot = workerSlot;
|
|
153
|
+
workerSlot++;
|
|
154
|
+
while (helpers.workerTasks.has(workerSlot) && workerSlot < MAX_WORKERS)
|
|
155
|
+
workerSlot++;
|
|
156
|
+
activeWorkers++;
|
|
157
|
+
if (action === "qa") {
|
|
158
|
+
logger.log(`[${sessionId}] Spawning QA for: ${task.title} (slot: ${slot})`);
|
|
159
|
+
const qaPromise = (async () => {
|
|
160
|
+
try {
|
|
161
|
+
await worker.runQaAgent(task, projectId, repoPath, slot);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
activeWorkers--;
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
runningTasks.push(qaPromise);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
logger.log(`[${sessionId}] Spawning worker for: ${task.title} (slot: ${slot}, workers: ${activeWorkers})`);
|
|
171
|
+
const buildPromise = (async () => {
|
|
172
|
+
try {
|
|
173
|
+
await worker.runBuildAgent(task, projectId, repoPaths, repoPath, sessionId, projectContext, slot);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
activeWorkers--;
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
runningTasks.push(buildPromise);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// ============================================================ //
|
|
183
|
+
// Slot 3: Tech Lead / CTO priority queue //
|
|
184
|
+
// ============================================================ //
|
|
185
|
+
if (!techLeadActive && !ctoActive) {
|
|
186
|
+
const tlHasWork = planningFindings.length > 0 || backlogFindings.length > 0;
|
|
187
|
+
if (tlHasWork && (activeWorkers + 1) <= 3) {
|
|
188
|
+
techLeadActive = true;
|
|
189
|
+
logger.log(`[${sessionId}] Spawning tech lead (planning: ${planningFindings.length}, backlog: ${backlogFindings.length})`);
|
|
190
|
+
const tlPromise = (async () => {
|
|
191
|
+
try {
|
|
192
|
+
await techLead.runTechLeadAgent(projectId, repoPath, sessionId, projectContext, userId);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
techLeadActive = false;
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
runningTasks.push(tlPromise);
|
|
199
|
+
}
|
|
200
|
+
else if (!tlHasWork && activeWorkers === 0) {
|
|
201
|
+
ctoActive = true;
|
|
202
|
+
logger.log(`[${sessionId}] Spawning CTO (all buckets empty)`);
|
|
203
|
+
const ctoPromise = (async () => {
|
|
204
|
+
try {
|
|
205
|
+
await cto.runCtoAgent(projectId, repoPath, sessionId, projectContext, userId, repoPaths, repoList, userDirections);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
ctoActive = false;
|
|
209
|
+
}
|
|
210
|
+
})();
|
|
211
|
+
runningTasks.push(ctoPromise);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
// Main loop
|
|
217
|
+
while (iteration < MAX_ITERATIONS) {
|
|
218
|
+
if (isStopped())
|
|
219
|
+
break;
|
|
220
|
+
iteration++;
|
|
221
|
+
await checkAndSpawn();
|
|
222
|
+
// If nothing is running and nothing to spawn, check one more time then exit
|
|
223
|
+
if (runningTasks.length === 0 && activeWorkers === 0 && !techLeadActive && !ctoActive) {
|
|
224
|
+
const anyWork = await data.findFirstTask(projectId, {
|
|
225
|
+
status: ["READY_TO_START", "PLANNED", "IN_PROGRESS", "IN_REVIEW"],
|
|
226
|
+
});
|
|
227
|
+
const anyFailed = await data.findFirstTask(projectId, { status: "FAILED" });
|
|
228
|
+
const anyPlanning = await data.findFirstFinding(projectId, { status: "PLANNING" });
|
|
229
|
+
const anyBacklog = await data.findFirstFinding(projectId, { status: ["NEW", "TRIAGED"] });
|
|
230
|
+
if (!anyWork && !anyFailed && !anyBacklog && !anyPlanning)
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
// Wait for at least one task to complete before re-checking
|
|
234
|
+
if (runningTasks.length > 0) {
|
|
235
|
+
const settledFlags = runningTasks.map(() => false);
|
|
236
|
+
runningTasks.forEach((p, i) => {
|
|
237
|
+
p.then(() => { settledFlags[i] = true; }).catch(() => { settledFlags[i] = true; });
|
|
238
|
+
});
|
|
239
|
+
await Promise.race(runningTasks.map((p) => p.catch(() => { })));
|
|
240
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
241
|
+
runningTasks = runningTasks.filter((_, i) => !settledFlags[i]);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Wait for ALL remaining tasks to finish before declaring complete
|
|
248
|
+
if (runningTasks.length > 0) {
|
|
249
|
+
logger.log(`[${sessionId}] Waiting for ${runningTasks.length} remaining agent(s) to finish...`);
|
|
250
|
+
await Promise.all(runningTasks.map((p) => p.catch(() => { })));
|
|
251
|
+
}
|
|
252
|
+
// Capture git diffs for the session
|
|
253
|
+
const diffs = {};
|
|
254
|
+
for (const dir of repoDirs) {
|
|
255
|
+
const oldHead = preWorkHeads[dir];
|
|
256
|
+
if (!oldHead)
|
|
257
|
+
continue;
|
|
258
|
+
try {
|
|
259
|
+
const newHead = helpers.gitRevParse(dir);
|
|
260
|
+
if (oldHead !== newHead) {
|
|
261
|
+
diffs[dir] = {
|
|
262
|
+
diff: helpers.gitDiff(dir, oldHead, newHead),
|
|
263
|
+
diffStat: helpers.gitDiffStat(dir, oldHead, newHead),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
logger.warn(`Failed to capture post-work diff for ${dir}: ${err}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (Object.keys(diffs).length > 0) {
|
|
272
|
+
const allDiffs = Object.values(diffs).map((d) => d.diff).join("\n");
|
|
273
|
+
const allDiffStats = Object.values(diffs).map((d) => d.diffStat).join("\n");
|
|
274
|
+
await data.updateSession(sessionId, {
|
|
275
|
+
metadata: { diff: allDiffs.slice(0, 50_000), diffStat: allDiffStats },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
logger.error(`Pipeline ${sessionId} failed: ${err}`);
|
|
281
|
+
await data.log("PIPELINE_ERROR", "Session", sessionId, userId, { error: String(err) });
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
helpers.workerTasks.clear();
|
|
285
|
+
helpers.slot3Info = null;
|
|
286
|
+
worker.clearSessions();
|
|
287
|
+
await data.updateSession(sessionId, { phase: "SHIP" });
|
|
288
|
+
data.emit(projectId, "phase_changed", { sessionId, phase: "SHIP" });
|
|
289
|
+
data.emit(projectId, "pipeline_complete", { sessionId });
|
|
290
|
+
// Generate and store run summary
|
|
291
|
+
const summary = await generateRunSummary(ctx, repoDirs, preWorkHeads).catch((err) => {
|
|
292
|
+
logger.warn(`Failed to generate run summary: ${err}`);
|
|
293
|
+
return null;
|
|
294
|
+
});
|
|
295
|
+
if (summary) {
|
|
296
|
+
const existingSession = await data.findSession(sessionId);
|
|
297
|
+
const existingMeta = existingSession?.metadata || {};
|
|
298
|
+
await data.updateSession(sessionId, {
|
|
299
|
+
metadata: { ...existingMeta, summary },
|
|
300
|
+
}).catch((err) => logger.warn(`Failed to store run summary: ${err}`));
|
|
301
|
+
data.emit(projectId, "pipeline_summary", { sessionId, summary });
|
|
302
|
+
await data.log("PIPELINE_SUMMARY", "Session", sessionId, userId, {
|
|
303
|
+
duration: summary.duration,
|
|
304
|
+
tasksCompleted: summary.tasksCompleted.length,
|
|
305
|
+
tasksFailed: summary.tasksFailed.length,
|
|
306
|
+
findingsCreated: summary.findingsCreated,
|
|
307
|
+
filesChanged: summary.filesChanged.length,
|
|
308
|
+
commits: summary.commitMessages.length,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
await data.updateSession(sessionId, { status: "TERMINATED", endedAt: new Date() });
|
|
312
|
+
await data.log("PIPELINE_FINISH", "Session", sessionId, userId);
|
|
313
|
+
logger.log(`[${sessionId}] All agents finished`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Generate a structured summary of a pipeline run.
|
|
318
|
+
* Portable — uses only PipelineDataSource + git helpers.
|
|
319
|
+
*/
|
|
320
|
+
async function generateRunSummary(ctx, repoDirs, preWorkHeads) {
|
|
321
|
+
const { data, helpers, sessionId, projectId } = ctx;
|
|
322
|
+
const { execSync } = await Promise.resolve().then(() => __importStar(require("child_process")));
|
|
323
|
+
const session = await data.findSession(sessionId);
|
|
324
|
+
const startedAt = session?.startedAt ?? new Date();
|
|
325
|
+
const endedAt = new Date();
|
|
326
|
+
const durationMs = endedAt.getTime() - new Date(startedAt).getTime();
|
|
327
|
+
const totalSeconds = Math.floor(durationMs / 1000);
|
|
328
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
329
|
+
const seconds = totalSeconds % 60;
|
|
330
|
+
const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
331
|
+
// Use data source to get completed/failed tasks (with time filter approximation)
|
|
332
|
+
const completedTasks = await data.findTasks(projectId, { status: "COMPLETED" });
|
|
333
|
+
const failedTasks = await data.findTasks(projectId, { status: "FAILED" });
|
|
334
|
+
const tasksCompleted = completedTasks
|
|
335
|
+
.filter((t) => new Date(t.updatedAt) >= new Date(startedAt))
|
|
336
|
+
.map((t) => ({
|
|
337
|
+
id: t.id,
|
|
338
|
+
title: t.title,
|
|
339
|
+
summary: t.metadata?.summary,
|
|
340
|
+
}));
|
|
341
|
+
const tasksFailed = failedTasks
|
|
342
|
+
.filter((t) => new Date(t.updatedAt) >= new Date(startedAt))
|
|
343
|
+
.map((t) => ({
|
|
344
|
+
id: t.id,
|
|
345
|
+
title: t.title,
|
|
346
|
+
error: t.metadata?.error,
|
|
347
|
+
}));
|
|
348
|
+
// Count findings created during this session
|
|
349
|
+
const allFindings = await data.findFindings(projectId, {});
|
|
350
|
+
const findingsCreated = allFindings.filter((f) => new Date(f.createdAt) >= new Date(startedAt)).length;
|
|
351
|
+
const filesChanged = [];
|
|
352
|
+
const commitMessages = [];
|
|
353
|
+
for (const dir of repoDirs) {
|
|
354
|
+
const oldHead = preWorkHeads[dir];
|
|
355
|
+
if (!oldHead)
|
|
356
|
+
continue;
|
|
357
|
+
try {
|
|
358
|
+
const newHead = helpers.gitRevParse(dir);
|
|
359
|
+
if (oldHead === newHead)
|
|
360
|
+
continue;
|
|
361
|
+
const diffStat = helpers.gitDiffStat(dir, oldHead, newHead);
|
|
362
|
+
const fileLines = diffStat.split("\n").filter((l) => l.includes("|"));
|
|
363
|
+
for (const line of fileLines) {
|
|
364
|
+
const fileName = line.split("|")[0].trim();
|
|
365
|
+
if (fileName)
|
|
366
|
+
filesChanged.push(fileName);
|
|
367
|
+
}
|
|
368
|
+
if (helpers.isValidSha(oldHead) && helpers.isValidSha(newHead)) {
|
|
369
|
+
try {
|
|
370
|
+
helpers.validateRepoPath(dir);
|
|
371
|
+
const logOutput = execSync(`git log --oneline ${oldHead}..${newHead}`, {
|
|
372
|
+
cwd: dir,
|
|
373
|
+
encoding: "utf-8",
|
|
374
|
+
});
|
|
375
|
+
for (const line of logOutput.trim().split("\n")) {
|
|
376
|
+
if (line.trim())
|
|
377
|
+
commitMessages.push(line.trim());
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// ignore — no commits
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// ignore
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
sessionId,
|
|
391
|
+
projectName: session?.project?.name ?? "Unknown",
|
|
392
|
+
duration,
|
|
393
|
+
tasksCompleted,
|
|
394
|
+
tasksFailed,
|
|
395
|
+
findingsCreated,
|
|
396
|
+
filesChanged,
|
|
397
|
+
commitMessages,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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 PipelineQueueService_1;
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.PipelineQueueService = void 0;
|
|
11
|
+
const common_1 = require("@nestjs/common");
|
|
12
|
+
/**
|
|
13
|
+
* Global singleton queue that ensures only one pipeline runs at a time.
|
|
14
|
+
* Other pipelines wait in FIFO order until the current one finishes.
|
|
15
|
+
*/
|
|
16
|
+
let PipelineQueueService = PipelineQueueService_1 = class PipelineQueueService {
|
|
17
|
+
logger = new common_1.Logger(PipelineQueueService_1.name);
|
|
18
|
+
running = null;
|
|
19
|
+
queue = [];
|
|
20
|
+
/** Callback set by PipelineService to actually start execution. */
|
|
21
|
+
startCallback = null;
|
|
22
|
+
/** Register the function that starts pipeline execution. */
|
|
23
|
+
onStartNext(cb) {
|
|
24
|
+
this.startCallback = cb;
|
|
25
|
+
}
|
|
26
|
+
/** True if a pipeline is currently executing. */
|
|
27
|
+
isRunning() {
|
|
28
|
+
return this.running !== null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the queue position for a session.
|
|
32
|
+
* -1 = not found, 0 = currently running, 1+ = waiting in queue.
|
|
33
|
+
*/
|
|
34
|
+
getQueuePosition(sessionId) {
|
|
35
|
+
if (this.running?.sessionId === sessionId)
|
|
36
|
+
return 0;
|
|
37
|
+
const idx = this.queue.findIndex((e) => e.sessionId === sessionId);
|
|
38
|
+
return idx === -1 ? -1 : idx + 1;
|
|
39
|
+
}
|
|
40
|
+
/** Number of entries waiting (not including the currently running one). */
|
|
41
|
+
getQueueLength() {
|
|
42
|
+
return this.queue.length;
|
|
43
|
+
}
|
|
44
|
+
/** Full status snapshot for the REST endpoint. */
|
|
45
|
+
getStatus() {
|
|
46
|
+
return {
|
|
47
|
+
running: this.running
|
|
48
|
+
? { sessionId: this.running.sessionId, projectId: this.running.projectId }
|
|
49
|
+
: null,
|
|
50
|
+
queue: this.queue.map((e) => ({
|
|
51
|
+
sessionId: e.sessionId,
|
|
52
|
+
projectId: e.projectId,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Add a pipeline to the queue.
|
|
58
|
+
* If the slot is empty, starts it immediately (position 0).
|
|
59
|
+
* Otherwise queues it (position 1+).
|
|
60
|
+
*/
|
|
61
|
+
enqueue(entry) {
|
|
62
|
+
if (!this.running) {
|
|
63
|
+
this.running = entry;
|
|
64
|
+
this.logger.log(`Pipeline ${entry.sessionId} starts immediately (no queue)`);
|
|
65
|
+
this.startCallback?.(entry);
|
|
66
|
+
return { position: 0 };
|
|
67
|
+
}
|
|
68
|
+
this.queue.push(entry);
|
|
69
|
+
const position = this.queue.length;
|
|
70
|
+
this.logger.log(`Pipeline ${entry.sessionId} queued at position ${position}`);
|
|
71
|
+
return { position };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Called when the current pipeline finishes (success or error).
|
|
75
|
+
* Promotes the next entry in the queue, if any.
|
|
76
|
+
*/
|
|
77
|
+
dequeue() {
|
|
78
|
+
const finished = this.running;
|
|
79
|
+
this.running = null;
|
|
80
|
+
if (finished) {
|
|
81
|
+
this.logger.log(`Pipeline ${finished.sessionId} finished, slot freed`);
|
|
82
|
+
}
|
|
83
|
+
if (this.queue.length > 0) {
|
|
84
|
+
const next = this.queue.shift();
|
|
85
|
+
this.running = next;
|
|
86
|
+
this.logger.log(`Pipeline ${next.sessionId} promoted from queue, starting`);
|
|
87
|
+
this.startCallback?.(next);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Cancel a session — remove from queue or mark running slot as free.
|
|
92
|
+
* Returns true if the session was found and removed.
|
|
93
|
+
*/
|
|
94
|
+
cancel(sessionId) {
|
|
95
|
+
// If it's the running entry, clear the slot (caller handles killing the process)
|
|
96
|
+
if (this.running?.sessionId === sessionId) {
|
|
97
|
+
this.logger.log(`Cancelling running pipeline ${sessionId}`);
|
|
98
|
+
this.running = null;
|
|
99
|
+
// Start the next queued entry
|
|
100
|
+
if (this.queue.length > 0) {
|
|
101
|
+
const next = this.queue.shift();
|
|
102
|
+
this.running = next;
|
|
103
|
+
this.logger.log(`Pipeline ${next.sessionId} promoted from queue after cancel`);
|
|
104
|
+
this.startCallback?.(next);
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
// If it's in the queue, remove it
|
|
109
|
+
const idx = this.queue.findIndex((e) => e.sessionId === sessionId);
|
|
110
|
+
if (idx !== -1) {
|
|
111
|
+
this.queue.splice(idx, 1);
|
|
112
|
+
this.logger.log(`Removed queued pipeline ${sessionId} from position ${idx + 1}`);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
exports.PipelineQueueService = PipelineQueueService;
|
|
119
|
+
exports.PipelineQueueService = PipelineQueueService = PipelineQueueService_1 = __decorate([
|
|
120
|
+
(0, common_1.Injectable)()
|
|
121
|
+
], PipelineQueueService);
|
|
@@ -0,0 +1,127 @@
|
|
|
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 PipelineTechLeadService_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.PipelineTechLeadService = 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 PipelineTechLeadService = PipelineTechLeadService_1 = class PipelineTechLeadService {
|
|
21
|
+
data;
|
|
22
|
+
helpers;
|
|
23
|
+
logger = new common_1.Logger(PipelineTechLeadService_1.name);
|
|
24
|
+
constructor(data, helpers) {
|
|
25
|
+
this.data = data;
|
|
26
|
+
this.helpers = helpers;
|
|
27
|
+
}
|
|
28
|
+
async runTechLeadAgent(projectId, repoPath, sessionId, projectContext, userId) {
|
|
29
|
+
this.data.emit(projectId, "phase_changed", { sessionId, phase: "PLAN", reason: "Tech Lead planning" });
|
|
30
|
+
this.logger.log(`[${sessionId}] Tech Lead: planning backlog findings`);
|
|
31
|
+
this.helpers.slot3Info = { role: "Tech Lead", task: "Planning backlog" };
|
|
32
|
+
this.helpers.emitAgentState(projectId);
|
|
33
|
+
// Priority 1: Resume interrupted PLANNING findings from previous runs
|
|
34
|
+
const planningFindings = await this.data.findFindings(projectId, {
|
|
35
|
+
status: "PLANNING",
|
|
36
|
+
orderBy: [{ severity: "asc" }, { createdAt: "asc" }],
|
|
37
|
+
take: 2,
|
|
38
|
+
});
|
|
39
|
+
// Priority 2: Pick up NEW/TRIAGED findings from backlog
|
|
40
|
+
const remainingSlots = 2 - planningFindings.length;
|
|
41
|
+
const newFindings = remainingSlots > 0
|
|
42
|
+
? await this.data.findFindings(projectId, {
|
|
43
|
+
status: ["NEW", "TRIAGED"],
|
|
44
|
+
orderBy: [{ severity: "asc" }, { createdAt: "asc" }],
|
|
45
|
+
take: remainingSlots,
|
|
46
|
+
})
|
|
47
|
+
: [];
|
|
48
|
+
const findings = [...planningFindings, ...newFindings];
|
|
49
|
+
if (findings.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
// Move NEW/TRIAGED findings to PLANNING status (already-PLANNING ones stay)
|
|
52
|
+
const toMarkPlanning = newFindings.filter((f) => f.status !== "PLANNING").map((f) => f.id);
|
|
53
|
+
if (toMarkPlanning.length > 0) {
|
|
54
|
+
await this.data.updateManyFindings(toMarkPlanning, { status: "PLANNING" });
|
|
55
|
+
}
|
|
56
|
+
const findingsSummary = findings.map((f) => ({
|
|
57
|
+
id: f.id,
|
|
58
|
+
title: f.title,
|
|
59
|
+
description: f.description,
|
|
60
|
+
severity: f.severity,
|
|
61
|
+
category: f.category,
|
|
62
|
+
}));
|
|
63
|
+
this.helpers.slot3Info = { role: "Tech Lead", task: `Planning: ${findings[0].title}` };
|
|
64
|
+
this.helpers.emitAgentState(projectId);
|
|
65
|
+
const planPrompt = (projectContext ? `Project context:\n${projectContext}\n\n` : "") +
|
|
66
|
+
`You are a Tech Lead. Analyze these findings and break each into concrete implementation tasks.\n` +
|
|
67
|
+
`Findings:\n${JSON.stringify(findingsSummary)}\n\n` +
|
|
68
|
+
`For EACH finding, create 1-3 specific implementation tasks. Each task should be:\n` +
|
|
69
|
+
`- Small enough for one developer to complete\n` +
|
|
70
|
+
`- Specific about what files/modules to change\n` +
|
|
71
|
+
`- Clear about the expected outcome\n\n` +
|
|
72
|
+
`Return JSON array: [{title, objective (1-2 sentences), priority (CRITICAL/HIGH/MEDIUM/LOW), effort (small/medium/large), findingId}]\n` +
|
|
73
|
+
`ONLY valid JSON.`;
|
|
74
|
+
const planResult = await this.helpers.spawnClaude(planPrompt, repoPath, "claude-sonnet-4-6", undefined, { projectId, phase: "PLAN" });
|
|
75
|
+
const tasks = this.helpers.parseClaudeJson(planResult.stdout);
|
|
76
|
+
let tasksCreated = 0;
|
|
77
|
+
if (tasks && Array.isArray(tasks)) {
|
|
78
|
+
for (const t of tasks) {
|
|
79
|
+
try {
|
|
80
|
+
let validFindingId = null;
|
|
81
|
+
if (t.findingId) {
|
|
82
|
+
const exists = await this.data.findFindingById(t.findingId);
|
|
83
|
+
if (exists)
|
|
84
|
+
validFindingId = t.findingId;
|
|
85
|
+
}
|
|
86
|
+
const created = await this.data.createTask({
|
|
87
|
+
projectId,
|
|
88
|
+
findingId: validFindingId,
|
|
89
|
+
title: t.title,
|
|
90
|
+
objective: t.objective || null,
|
|
91
|
+
priority: this.helpers.toTaskPriority(t.priority),
|
|
92
|
+
status: "READY_TO_START",
|
|
93
|
+
effort: t.effort || null,
|
|
94
|
+
});
|
|
95
|
+
// Add tech-lead comment documenting creation context
|
|
96
|
+
const linkedFinding = findingsSummary.find((f) => f.id === validFindingId);
|
|
97
|
+
await this.data.createTaskComment({
|
|
98
|
+
taskId: created.id,
|
|
99
|
+
author: "tech-lead",
|
|
100
|
+
content: `Created from finding: "${linkedFinding?.title || "unknown"}". ${t.objective || ""}. Effort: ${t.effort || "unknown"}.`,
|
|
101
|
+
});
|
|
102
|
+
this.data.emit(projectId, "task_created", { id: created.id, title: created.title, priority: created.priority, status: created.status });
|
|
103
|
+
if (validFindingId) {
|
|
104
|
+
await this.data.updateFinding(validFindingId, { status: "TASKED" });
|
|
105
|
+
}
|
|
106
|
+
tasksCreated++;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
this.logger.warn(`Failed to create task: ${err}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await this.data.log("WORK_COMPLETED", "Session", sessionId, userId, {
|
|
114
|
+
type: "PLAN",
|
|
115
|
+
tasksCreated,
|
|
116
|
+
});
|
|
117
|
+
// Clear slot 3 tracking
|
|
118
|
+
this.helpers.slot3Info = null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
exports.PipelineTechLeadService = PipelineTechLeadService;
|
|
122
|
+
exports.PipelineTechLeadService = PipelineTechLeadService = PipelineTechLeadService_1 = __decorate([
|
|
123
|
+
(0, common_1.Injectable)(),
|
|
124
|
+
__param(0, (0, common_2.Optional)()),
|
|
125
|
+
__param(0, (0, common_2.Inject)("PIPELINE_DATA")),
|
|
126
|
+
__metadata("design:paramtypes", [Object, pipeline_helpers_service_1.PipelineHelpersService])
|
|
127
|
+
], PipelineTechLeadService);
|