duoops 0.1.9 → 0.2.1
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/README.md +151 -63
- package/data/aws_machine_power_profiles.json +54 -0
- package/data/cpu_physical_specs.json +105 -0
- package/data/cpu_power_profiles.json +275 -0
- package/data/gcp_machine_power_profiles.json +1802 -0
- package/data/runtime-pue-mappings.json +183 -0
- package/dist/commands/autofix-ci.d.ts +13 -0
- package/dist/commands/autofix-ci.js +114 -0
- package/dist/commands/autofix.d.ts +5 -0
- package/dist/commands/autofix.js +11 -0
- package/dist/commands/init.js +104 -33
- package/dist/commands/mcp/deploy.d.ts +13 -0
- package/dist/commands/mcp/deploy.js +139 -0
- package/dist/commands/measure/calculate.js +2 -2
- package/dist/commands/portal.js +421 -6
- package/dist/lib/ai/agent.js +1 -0
- package/dist/lib/ai/tools/editing.js +28 -13
- package/dist/lib/ai/tools/gitlab.js +8 -4
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/gcloud.d.ts +7 -0
- package/dist/lib/gcloud.js +105 -0
- package/dist/lib/gitlab/pipelines-service.d.ts +23 -0
- package/dist/lib/gitlab/pipelines-service.js +146 -0
- package/dist/lib/gitlab/runner-service.d.ts +11 -0
- package/dist/lib/gitlab/runner-service.js +15 -0
- package/dist/lib/portal/settings.d.ts +3 -0
- package/dist/lib/portal/settings.js +48 -0
- package/dist/lib/scaffold.d.ts +5 -0
- package/dist/lib/scaffold.js +32 -0
- package/dist/portal/assets/HomeDashboard-DlkwSyKx.js +1 -0
- package/dist/portal/assets/JobDetailsDrawer-7kXXMSH8.js +1 -0
- package/dist/portal/assets/JobsDashboard-D4pNc9TM.js +1 -0
- package/dist/portal/assets/MetricsDashboard-BcgzvzBz.js +1 -0
- package/dist/portal/assets/PipelinesDashboard-BNrSM9GB.js +1 -0
- package/dist/portal/assets/allPaths-CXDKahbk.js +1 -0
- package/dist/portal/assets/allPathsLoader-BF5PAx2c.js +2 -0
- package/dist/portal/assets/cache-YerT0Slh.js +6 -0
- package/dist/portal/assets/core-Cz8f3oSB.js +19 -0
- package/dist/portal/assets/{index-C54ZhVUo.js → index-B9sNUqEC.js} +1 -1
- package/dist/portal/assets/index-BWa_E8Y7.css +1 -0
- package/dist/portal/assets/index-Bp4RqK05.js +1 -0
- package/dist/portal/assets/index-DW6Qp0d6.js +64 -0
- package/dist/portal/assets/index-Uc4Xhv31.js +1 -0
- package/dist/portal/assets/progressBar-C4SmnGeZ.js +1 -0
- package/dist/portal/assets/splitPathsBySizeLoader-C-T9_API.js +1 -0
- package/dist/portal/index.html +2 -2
- package/oclif.manifest.json +147 -2
- package/package.json +2 -1
- package/templates/.gitlab/duo/flows/duoops.yaml +114 -0
- package/templates/agents/agent.yml +45 -0
- package/templates/duoops-autofix-component.yml +52 -0
- package/templates/flows/flow.yml +283 -0
- package/dist/portal/assets/MetricsDashboard-Bnj-jtu6.js +0 -27
- package/dist/portal/assets/index-B1SGDQNX.css +0 -1
- package/dist/portal/assets/index-Bk8OVV7a.js +0 -106
|
@@ -80,13 +80,13 @@ export default class CarbonCalculate extends Command {
|
|
|
80
80
|
this.log(gray(`Loaded ${cpuTimeseries.length} CPU points, ${ramUsedTimeseries.length} RAM points`));
|
|
81
81
|
// Initialize dependencies
|
|
82
82
|
// Locate data directory relative to the compiled file or project root
|
|
83
|
-
// In production (dist), __dirname is .../dist/commands/
|
|
83
|
+
// In production (dist), __dirname is .../dist/commands/measure
|
|
84
84
|
// data is at .../data
|
|
85
85
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
86
86
|
// Try to resolve data directory
|
|
87
87
|
// 1. From source (development)
|
|
88
88
|
// 2. From dist (production/built)
|
|
89
|
-
let dataDir = path.resolve(__dirname, '
|
|
89
|
+
let dataDir = path.resolve(__dirname, '../../../data');
|
|
90
90
|
if (!fs.existsSync(dataDir)) {
|
|
91
91
|
// Fallback for different structures or if run from different location
|
|
92
92
|
dataDir = path.resolve(process.cwd(), 'data');
|
package/dist/commands/portal.js
CHANGED
|
@@ -6,15 +6,19 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import open from 'open';
|
|
9
|
-
import { streamAgent } from '../lib/ai/agent.js';
|
|
9
|
+
import { runAgent, streamAgent } from '../lib/ai/agent.js';
|
|
10
10
|
import { configManager } from '../lib/config.js';
|
|
11
|
+
import { getPipelineProvider } from '../lib/gitlab/index.js';
|
|
12
|
+
import { fetchPortalPipelines } from '../lib/gitlab/pipelines-service.js';
|
|
13
|
+
import { fetchProjectRunners } from '../lib/gitlab/runner-service.js';
|
|
11
14
|
import { fetchAvailableProjects, fetchCarbonMetrics } from '../lib/measure/bigquery-service.js';
|
|
15
|
+
import { getPortalBudgets, savePortalBudgets } from '../lib/portal/settings.js';
|
|
12
16
|
export default class Portal extends Command {
|
|
13
17
|
static description = 'Launch the DuoOps web portal';
|
|
14
18
|
static flags = {
|
|
15
19
|
port: Flags.integer({
|
|
16
20
|
char: 'p',
|
|
17
|
-
default:
|
|
21
|
+
default: 58_327,
|
|
18
22
|
description: 'Port to run the portal on',
|
|
19
23
|
}),
|
|
20
24
|
};
|
|
@@ -73,8 +77,9 @@ export default class Portal extends Command {
|
|
|
73
77
|
res.json([]);
|
|
74
78
|
return;
|
|
75
79
|
}
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
// Log error but return empty array so frontend doesn't break if BQ is down/misconfigured
|
|
81
|
+
this.warn(`Failed to fetch projects: ${error}`);
|
|
82
|
+
res.json([]);
|
|
78
83
|
}
|
|
79
84
|
});
|
|
80
85
|
app.get('/api/metrics', async (req, res) => {
|
|
@@ -108,6 +113,183 @@ export default class Portal extends Command {
|
|
|
108
113
|
bigqueryActive,
|
|
109
114
|
});
|
|
110
115
|
});
|
|
116
|
+
app.get('/api/pipelines', async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const config = configManager.get();
|
|
119
|
+
const queryProjectId = Array.isArray(req.query.projectId) ? req.query.projectId[0] : req.query.projectId;
|
|
120
|
+
const queryLimit = Array.isArray(req.query.limit) ? req.query.limit[0] : req.query.limit;
|
|
121
|
+
const queryPage = Array.isArray(req.query.page) ? req.query.page[0] : req.query.page;
|
|
122
|
+
const targetProjectId = queryProjectId
|
|
123
|
+
? String(queryProjectId)
|
|
124
|
+
: config.defaultProjectId;
|
|
125
|
+
if (!targetProjectId) {
|
|
126
|
+
res.status(400).json({ error: 'Project ID is required and no default is configured' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const parsedLimit = queryLimit ? Number(queryLimit) : undefined;
|
|
130
|
+
const parsedPage = queryPage ? Number(queryPage) : undefined;
|
|
131
|
+
const result = await fetchPortalPipelines(targetProjectId, {
|
|
132
|
+
limit: Number.isFinite(parsedLimit) && parsedLimit !== undefined
|
|
133
|
+
? parsedLimit
|
|
134
|
+
: undefined,
|
|
135
|
+
page: Number.isFinite(parsedPage) && parsedPage !== undefined
|
|
136
|
+
? parsedPage
|
|
137
|
+
: undefined,
|
|
138
|
+
});
|
|
139
|
+
res.json({
|
|
140
|
+
pagination: {
|
|
141
|
+
hasNextPage: result.hasNextPage,
|
|
142
|
+
hasPrevPage: result.hasPrevPage,
|
|
143
|
+
page: result.page,
|
|
144
|
+
perPage: result.perPage,
|
|
145
|
+
},
|
|
146
|
+
pipelines: result.pipelines,
|
|
147
|
+
projectId: targetProjectId,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
this.error(String(error), { exit: false });
|
|
152
|
+
res.status(500).json({ error: String(error) });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
app.get('/api/dashboard', async (req, res) => {
|
|
156
|
+
try {
|
|
157
|
+
const config = configManager.get();
|
|
158
|
+
const queryProjectId = Array.isArray(req.query.projectId) ? req.query.projectId[0] : req.query.projectId;
|
|
159
|
+
const targetProjectId = queryProjectId
|
|
160
|
+
? String(queryProjectId)
|
|
161
|
+
: config.defaultProjectId;
|
|
162
|
+
if (!targetProjectId) {
|
|
163
|
+
res.status(400).json({ error: 'Project ID is required and no default is configured' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let metrics = [];
|
|
167
|
+
try {
|
|
168
|
+
metrics = await fetchCarbonMetrics(targetProjectId, 200);
|
|
169
|
+
}
|
|
170
|
+
catch (metricError) {
|
|
171
|
+
if (!String(metricError).includes('not configured')) {
|
|
172
|
+
this.warn(`Failed to load metrics for dashboard: ${metricError}`);
|
|
173
|
+
}
|
|
174
|
+
metrics = [];
|
|
175
|
+
}
|
|
176
|
+
const summary = buildDashboardSummary(metrics);
|
|
177
|
+
const budgetSettings = getPortalBudgets();
|
|
178
|
+
const budgets = buildBudgetStatuses(summary, budgetSettings);
|
|
179
|
+
const alerts = buildAlerts(budgets);
|
|
180
|
+
let runnerHealth = [];
|
|
181
|
+
try {
|
|
182
|
+
runnerHealth = await fetchProjectRunners(targetProjectId);
|
|
183
|
+
}
|
|
184
|
+
catch (runnerError) {
|
|
185
|
+
this.warn(`Failed to fetch runner health: ${runnerError}`);
|
|
186
|
+
runnerHealth = [];
|
|
187
|
+
}
|
|
188
|
+
const recommendations = await buildRecommendations({
|
|
189
|
+
metrics,
|
|
190
|
+
onError: (recommendationError) => {
|
|
191
|
+
this.warn(`Failed to generate dashboard recommendations: ${recommendationError}`);
|
|
192
|
+
},
|
|
193
|
+
projectId: targetProjectId,
|
|
194
|
+
summary,
|
|
195
|
+
});
|
|
196
|
+
res.json({
|
|
197
|
+
alerts,
|
|
198
|
+
budgets,
|
|
199
|
+
budgetSettings,
|
|
200
|
+
metricsPreview: metrics.slice(0, 10),
|
|
201
|
+
projectId: targetProjectId,
|
|
202
|
+
recommendations,
|
|
203
|
+
runnerHealth,
|
|
204
|
+
summary,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
this.error(String(error), { exit: false });
|
|
209
|
+
res.status(500).json({ error: String(error) });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
app.get('/api/budgets', (req, res) => {
|
|
213
|
+
try {
|
|
214
|
+
const budgets = getPortalBudgets();
|
|
215
|
+
res.json({ budgets });
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
this.error(String(error), { exit: false });
|
|
219
|
+
res.status(500).json({ error: String(error) });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
app.put('/api/budgets', (req, res) => {
|
|
223
|
+
try {
|
|
224
|
+
const { budgets } = req.body;
|
|
225
|
+
if (!Array.isArray(budgets) || budgets.length === 0) {
|
|
226
|
+
res.status(400).json({ error: 'Budgets array is required' });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const sanitized = budgets
|
|
230
|
+
.map((budget) => ({
|
|
231
|
+
id: String(budget.id ?? ''),
|
|
232
|
+
limit: Number(budget.limit),
|
|
233
|
+
name: String(budget.name ?? ''),
|
|
234
|
+
period: String(budget.period ?? ''),
|
|
235
|
+
unit: String(budget.unit ?? ''),
|
|
236
|
+
}))
|
|
237
|
+
.filter((budget) => budget.id && budget.name && budget.unit && Number.isFinite(budget.limit) && budget.limit > 0);
|
|
238
|
+
if (sanitized.length === 0) {
|
|
239
|
+
res.status(400).json({ error: 'No valid budgets provided' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
savePortalBudgets(sanitized);
|
|
243
|
+
res.json({ budgets: getPortalBudgets() });
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
this.error(String(error), { exit: false });
|
|
247
|
+
res.status(500).json({ error: String(error) });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
app.get('/api/runners', async (req, res) => {
|
|
251
|
+
try {
|
|
252
|
+
const targetProjectId = resolveProjectId(req);
|
|
253
|
+
if (!targetProjectId) {
|
|
254
|
+
res.status(400).json({ error: 'Project ID is required and no default is configured' });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const runners = await fetchProjectRunners(targetProjectId);
|
|
258
|
+
res.json({ projectId: targetProjectId, runners });
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.error(String(error), { exit: false });
|
|
262
|
+
res.status(500).json({ error: String(error) });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
app.post('/api/autofix', async (req, res) => {
|
|
266
|
+
try {
|
|
267
|
+
const targetProjectId = resolveProjectId(req);
|
|
268
|
+
if (!targetProjectId) {
|
|
269
|
+
res.status(400).json({ error: 'Project ID is required and no default is configured' });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const provider = getPipelineProvider();
|
|
273
|
+
const latest = await fetchLatestFailedPipeline(provider, targetProjectId);
|
|
274
|
+
if (!latest) {
|
|
275
|
+
res.status(404).json({ error: 'No failing pipelines found to inspect.' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const jobs = await provider.listJobs(targetProjectId, latest.id);
|
|
279
|
+
const prompt = buildAutofixPrompt(latest, jobs);
|
|
280
|
+
const analysis = await runAgent(prompt, { projectId: targetProjectId });
|
|
281
|
+
res.json({
|
|
282
|
+
analysis,
|
|
283
|
+
jobs,
|
|
284
|
+
pipeline: latest,
|
|
285
|
+
projectId: targetProjectId,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
this.error(String(error), { exit: false });
|
|
290
|
+
res.status(500).json({ error: String(error) });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
111
293
|
// Serve Static Files (Frontend)
|
|
112
294
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
113
295
|
// Try resolving relative to the compiled command file (dist/commands/portal.js)
|
|
@@ -134,8 +316,241 @@ export default class Portal extends Command {
|
|
|
134
316
|
});
|
|
135
317
|
app.listen(port, async () => {
|
|
136
318
|
const url = `http://localhost:${port}`;
|
|
137
|
-
|
|
138
|
-
|
|
319
|
+
const query = new URLSearchParams();
|
|
320
|
+
const defaultTab = process.env.DUOOPS_PORTAL_DEFAULT_TAB;
|
|
321
|
+
const presetMessage = process.env.DUOOPS_PORTAL_PRESET_MESSAGE;
|
|
322
|
+
if (defaultTab) {
|
|
323
|
+
query.set('tab', defaultTab);
|
|
324
|
+
}
|
|
325
|
+
if (presetMessage) {
|
|
326
|
+
query.set('preset', presetMessage);
|
|
327
|
+
}
|
|
328
|
+
const finalUrl = query.size > 0 ? `${url}?${query.toString()}` : url;
|
|
329
|
+
this.log(`🚀 DuoOps Portal running at ${finalUrl}`);
|
|
330
|
+
await open(finalUrl);
|
|
139
331
|
});
|
|
140
332
|
}
|
|
141
333
|
}
|
|
334
|
+
function buildDashboardSummary(metrics) {
|
|
335
|
+
if (!metrics || metrics.length === 0) {
|
|
336
|
+
return {
|
|
337
|
+
avgEmissions: 0,
|
|
338
|
+
avgRuntime: 0,
|
|
339
|
+
jobCount: 0,
|
|
340
|
+
totalEmissions: 0,
|
|
341
|
+
totalEnergy: 0,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
let totalEmissions = 0;
|
|
345
|
+
let totalEnergy = 0;
|
|
346
|
+
let totalRuntime = 0;
|
|
347
|
+
let lastIngestedAt;
|
|
348
|
+
for (const metric of metrics) {
|
|
349
|
+
totalEmissions += Number(metric.total_emissions_g ?? 0);
|
|
350
|
+
totalEnergy += Number(metric.energy_kwh ?? 0);
|
|
351
|
+
totalRuntime += Number(metric.runtime_seconds ?? 0);
|
|
352
|
+
const ingested = typeof metric.ingested_at === 'object' && metric.ingested_at !== null
|
|
353
|
+
? String(metric.ingested_at.value ?? '')
|
|
354
|
+
: undefined;
|
|
355
|
+
if (ingested && (!lastIngestedAt || ingested > lastIngestedAt)) {
|
|
356
|
+
lastIngestedAt = ingested;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const jobCount = metrics.length;
|
|
360
|
+
const avgEmissions = jobCount === 0 ? 0 : totalEmissions / jobCount;
|
|
361
|
+
const avgRuntime = jobCount === 0 ? 0 : totalRuntime / jobCount;
|
|
362
|
+
return {
|
|
363
|
+
avgEmissions,
|
|
364
|
+
avgRuntime,
|
|
365
|
+
jobCount,
|
|
366
|
+
lastIngestedAt,
|
|
367
|
+
totalEmissions,
|
|
368
|
+
totalEnergy,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function buildBudgetStatuses(summary, configs) {
|
|
372
|
+
return configs.map((config) => {
|
|
373
|
+
const used = getBudgetUsage(config.id, summary);
|
|
374
|
+
const status = computeBudgetStatus(used, config.limit);
|
|
375
|
+
const percent = config.limit === 0 ? 0 : (used / config.limit) * 100;
|
|
376
|
+
return {
|
|
377
|
+
id: config.id,
|
|
378
|
+
limit: config.limit,
|
|
379
|
+
name: config.name,
|
|
380
|
+
percent,
|
|
381
|
+
period: config.period,
|
|
382
|
+
status,
|
|
383
|
+
unit: config.unit,
|
|
384
|
+
used,
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
function computeBudgetStatus(used, limit) {
|
|
389
|
+
if (limit === 0)
|
|
390
|
+
return 'ok';
|
|
391
|
+
const ratio = used / limit;
|
|
392
|
+
if (ratio >= 1)
|
|
393
|
+
return 'breached';
|
|
394
|
+
if (ratio >= 0.85)
|
|
395
|
+
return 'warning';
|
|
396
|
+
return 'ok';
|
|
397
|
+
}
|
|
398
|
+
function getBudgetUsage(budgetId, summary) {
|
|
399
|
+
switch (budgetId) {
|
|
400
|
+
case 'runtime': {
|
|
401
|
+
return summary.avgRuntime * summary.jobCount;
|
|
402
|
+
}
|
|
403
|
+
default: {
|
|
404
|
+
return summary.totalEmissions;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function buildAlerts(budgets) {
|
|
409
|
+
return budgets
|
|
410
|
+
.filter((budget) => budget.status !== 'ok')
|
|
411
|
+
.map((budget) => ({
|
|
412
|
+
id: `${budget.id}-alert`,
|
|
413
|
+
message: budget.status === 'breached'
|
|
414
|
+
? `${budget.name} exceeded by ${(budget.used - budget.limit).toFixed(1)} ${budget.unit}.`
|
|
415
|
+
: `${budget.name} is at ${budget.percent.toFixed(0)}% of limit.`,
|
|
416
|
+
relatedBudgetId: budget.id,
|
|
417
|
+
severity: budget.status === 'breached' ? 'critical' : 'warning',
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
async function buildRecommendations(options) {
|
|
421
|
+
const { metrics, onError, projectId, summary } = options;
|
|
422
|
+
const topJobs = metrics
|
|
423
|
+
.filter((m) => typeof m.total_emissions_g === 'number')
|
|
424
|
+
.sort((a, b) => Number(b.total_emissions_g ?? 0) - Number(a.total_emissions_g ?? 0))
|
|
425
|
+
.slice(0, 5)
|
|
426
|
+
.map((job) => ({
|
|
427
|
+
emissions: Number(job.total_emissions_g ?? 0),
|
|
428
|
+
jobId: job.gitlab_job_id,
|
|
429
|
+
machineType: job.machine_type,
|
|
430
|
+
runtime: job.runtime_seconds,
|
|
431
|
+
}));
|
|
432
|
+
const prompt = `You are advising on sustainable CI pipelines. Given this summary JSON:\n${JSON.stringify({
|
|
433
|
+
summary,
|
|
434
|
+
topJobs,
|
|
435
|
+
})}\nSuggest three concrete optimization ideas. Respond with a JSON array of objects like {"title": "...", "description": "..."}`;
|
|
436
|
+
try {
|
|
437
|
+
const response = await runAgent(prompt, { projectId });
|
|
438
|
+
const parsed = parseRecommendations(response);
|
|
439
|
+
if (parsed.length > 0) {
|
|
440
|
+
return parsed.map((item, index) => ({
|
|
441
|
+
description: item.description,
|
|
442
|
+
id: `agent-${index}`,
|
|
443
|
+
source: 'agent',
|
|
444
|
+
title: item.title,
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
onError?.(error);
|
|
450
|
+
}
|
|
451
|
+
return buildHeuristicRecommendations(summary, metrics);
|
|
452
|
+
}
|
|
453
|
+
function parseRecommendations(text) {
|
|
454
|
+
try {
|
|
455
|
+
const data = JSON.parse(text);
|
|
456
|
+
if (Array.isArray(data)) {
|
|
457
|
+
return data
|
|
458
|
+
.map((entry) => ({
|
|
459
|
+
description: typeof entry.description === 'string' ? entry.description : '',
|
|
460
|
+
title: typeof entry.title === 'string' ? entry.title : '',
|
|
461
|
+
}))
|
|
462
|
+
.filter((entry) => entry.title);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// ignore parse errors
|
|
467
|
+
}
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
function buildHeuristicRecommendations(summary, metrics) {
|
|
471
|
+
const recommendations = [];
|
|
472
|
+
if (summary.totalEmissions > 0) {
|
|
473
|
+
recommendations.push({
|
|
474
|
+
description: 'Cache dependencies and artifacts across pipelines to avoid repeating high-emission stages on every run.',
|
|
475
|
+
id: 'heuristic-cache',
|
|
476
|
+
source: 'heuristic',
|
|
477
|
+
title: 'Reduce redundant work',
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
const longestJob = metrics
|
|
481
|
+
.filter((m) => typeof m.runtime_seconds === 'number')
|
|
482
|
+
.sort((a, b) => Number(b.runtime_seconds ?? 0) - Number(a.runtime_seconds ?? 0))[0];
|
|
483
|
+
if (longestJob &&
|
|
484
|
+
typeof longestJob.runtime_seconds === 'number' &&
|
|
485
|
+
summary.avgRuntime > 0 &&
|
|
486
|
+
Number(longestJob.runtime_seconds) > summary.avgRuntime * 1.25) {
|
|
487
|
+
recommendations.push({
|
|
488
|
+
description: `Job ${longestJob.gitlab_job_id ?? ''} is much slower than average—consider splitting it into parallel stages or running it on a larger runner.`,
|
|
489
|
+
id: 'heuristic-longest',
|
|
490
|
+
source: 'heuristic',
|
|
491
|
+
title: 'Investigate the slowest job',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (summary.totalEnergy > 0 && summary.totalEmissions / (summary.totalEnergy * 1000) > 0.8) {
|
|
495
|
+
recommendations.push({
|
|
496
|
+
description: 'Energy intensity is trending high. Shift long-running stages to greener regions or off-peak windows.',
|
|
497
|
+
id: 'heuristic-intensity',
|
|
498
|
+
source: 'heuristic',
|
|
499
|
+
title: 'Schedule jobs for greener power',
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
if (recommendations.length === 0) {
|
|
503
|
+
recommendations.push({
|
|
504
|
+
description: 'Track a carbon budget per pipeline and alert when emissions spike after merges.',
|
|
505
|
+
id: 'heuristic-default',
|
|
506
|
+
source: 'heuristic',
|
|
507
|
+
title: 'Establish sustainability guardrails',
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return recommendations.slice(0, 3);
|
|
511
|
+
}
|
|
512
|
+
function resolveProjectId(req) {
|
|
513
|
+
const config = configManager.get();
|
|
514
|
+
const queryProjectId = Array.isArray(req.query.projectId) ? req.query.projectId[0] : req.query.projectId;
|
|
515
|
+
const bodyProjectId = typeof req.body === 'object' && req.body !== null && 'projectId' in req.body
|
|
516
|
+
? String(req.body.projectId ?? '')
|
|
517
|
+
: undefined;
|
|
518
|
+
const targetProjectId = bodyProjectId && bodyProjectId.trim().length > 0
|
|
519
|
+
? bodyProjectId
|
|
520
|
+
: queryProjectId
|
|
521
|
+
? String(queryProjectId)
|
|
522
|
+
: config.defaultProjectId;
|
|
523
|
+
return targetProjectId ?? null;
|
|
524
|
+
}
|
|
525
|
+
async function fetchLatestFailedPipeline(provider, projectId) {
|
|
526
|
+
const pipelines = await provider.listPipelines(projectId, { perPage: 10, status: 'failed' });
|
|
527
|
+
const failed = pipelines.find((pipeline) => pipeline.status === 'failed');
|
|
528
|
+
return failed ?? null;
|
|
529
|
+
}
|
|
530
|
+
function buildAutofixPrompt(pipeline, jobs) {
|
|
531
|
+
const jobSummary = jobs.length === 0
|
|
532
|
+
? 'No jobs were returned for this pipeline.'
|
|
533
|
+
: jobs
|
|
534
|
+
.map((job) => `• Job ${job.id} (${job.name}) in stage ${job.stage} - status: ${job.status}${job.duration ? `, duration: ${job.duration}s` : ''}`)
|
|
535
|
+
.join('\n');
|
|
536
|
+
return `You are DuoOps, an AI pair engineer focused on CI/CD reliability.
|
|
537
|
+
Inspect the following failing pipeline and propose actionable fixes.
|
|
538
|
+
|
|
539
|
+
Pipeline:
|
|
540
|
+
- ID: ${pipeline.id}
|
|
541
|
+
- Ref: ${pipeline.ref}
|
|
542
|
+
- Status: ${pipeline.status}
|
|
543
|
+
- SHA: ${pipeline.sha}
|
|
544
|
+
- URL: ${pipeline.webUrl ?? 'n/a'}
|
|
545
|
+
|
|
546
|
+
Jobs:
|
|
547
|
+
${jobSummary}
|
|
548
|
+
|
|
549
|
+
Instructions:
|
|
550
|
+
1. Identify the most likely cause of the failure based on the jobs above.
|
|
551
|
+
2. Propose concrete remediation steps, referencing jobs and stages explicitly.
|
|
552
|
+
3. Suggest any GitLab CI configuration changes or code adjustments to prevent this regression.
|
|
553
|
+
4. Outline validation steps once the fix is applied.
|
|
554
|
+
|
|
555
|
+
Respond in Markdown with clear sections: Root Cause, Fix Plan, Validation.`;
|
|
556
|
+
}
|
package/dist/lib/ai/agent.js
CHANGED
|
@@ -18,6 +18,7 @@ function createSystemPrompt(hasProjectId) {
|
|
|
18
18
|
: 'If a GitLab project has not been configured yet, ask the user to select one before running GitLab tools.';
|
|
19
19
|
return `You are a DevOps assistant.
|
|
20
20
|
${projectLine}
|
|
21
|
+
Before changing CI configs or runner scripts, inspect the DuoOps CLI source (e.g. src/commands/measure/*) and confirm whether the tool already bundles required data under the package's data/ directory; avoid inventing placeholder directories or files when the CLI provides them.
|
|
21
22
|
Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
|
|
22
23
|
}
|
|
23
24
|
function createTools(projectId) {
|
|
@@ -8,34 +8,49 @@ import { z } from 'zod';
|
|
|
8
8
|
import { ensureDuoOpsDir, PATCHES_DIR } from '../../state.js';
|
|
9
9
|
const execAsync = promisify(exec);
|
|
10
10
|
/* eslint-disable camelcase */
|
|
11
|
-
const patchFileSchema = z
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const patchFileSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
filePath: z.string().describe('Relative path to the file to edit').optional(),
|
|
14
|
+
newString: z.string().describe('The new content to replace with').optional(),
|
|
15
|
+
oldString: z.string().describe('The exact existing content to search for and replace').optional(),
|
|
16
|
+
original_content: z.string().describe('Alias for oldString used by some agents').optional(),
|
|
17
|
+
patched_content: z.string().describe('Alias for newString used by some agents').optional(),
|
|
18
|
+
path: z.string().describe('Alias for filePath used by some agents').optional(),
|
|
19
|
+
})
|
|
20
|
+
.refine((args) => Boolean((args.filePath ?? args.path) &&
|
|
21
|
+
(args.oldString ?? args.original_content) &&
|
|
22
|
+
(args.newString ?? args.patched_content)), {
|
|
23
|
+
message: 'filePath/path, oldString/original_content, and newString/patched_content are required',
|
|
15
24
|
});
|
|
16
25
|
export const editingTools = {
|
|
17
26
|
patch_file: tool({
|
|
18
27
|
description: 'Safely edit a file by creating and applying a reversible patch',
|
|
19
28
|
async execute(args) {
|
|
20
29
|
try {
|
|
21
|
-
const
|
|
30
|
+
const filePath = args.filePath ?? args.path;
|
|
31
|
+
const oldString = args.oldString ?? args.original_content;
|
|
32
|
+
const newString = args.newString ?? args.patched_content;
|
|
33
|
+
if (!filePath || !oldString || !newString) {
|
|
34
|
+
return { error: 'filePath/path, oldString/original_content, and newString/patched_content are required' };
|
|
35
|
+
}
|
|
36
|
+
const fullPath = path.resolve(process.cwd(), filePath);
|
|
22
37
|
if (!fs.existsSync(fullPath)) {
|
|
23
|
-
return { error: `File not found: ${
|
|
38
|
+
return { error: `File not found: ${filePath}` };
|
|
24
39
|
}
|
|
25
40
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
26
41
|
// Safety check: Ensure oldString exists exactly once to avoid ambiguity
|
|
27
42
|
// Or at least exists.
|
|
28
|
-
if (!content.includes(
|
|
29
|
-
return { error: `Could not find exact match for oldString in ${
|
|
43
|
+
if (!content.includes(oldString)) {
|
|
44
|
+
return { error: `Could not find exact match for oldString in ${filePath}. Please read the file again to ensure accuracy.` };
|
|
30
45
|
}
|
|
31
|
-
const newContent = content.replaceAll(
|
|
46
|
+
const newContent = content.replaceAll(oldString, newString);
|
|
32
47
|
// Generate Unified Diff
|
|
33
48
|
// createPatch(fileName, oldStr, newStr, oldHeader, newHeader)
|
|
34
|
-
const patchContent = createPatch(
|
|
49
|
+
const patchContent = createPatch(filePath, content, newContent);
|
|
35
50
|
// Save patch
|
|
36
51
|
ensureDuoOpsDir();
|
|
37
52
|
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
|
|
38
|
-
const patchName = `${path.basename(
|
|
53
|
+
const patchName = `${path.basename(filePath)}-${timestamp}.patch`;
|
|
39
54
|
const patchPath = path.join(PATCHES_DIR, patchName);
|
|
40
55
|
fs.writeFileSync(patchPath, patchContent);
|
|
41
56
|
// Apply patch using git apply
|
|
@@ -43,7 +58,7 @@ export const editingTools = {
|
|
|
43
58
|
// --ignore-space-change might be useful, but let's be strict first
|
|
44
59
|
await execAsync(`git apply "${patchPath}"`);
|
|
45
60
|
return {
|
|
46
|
-
message: `Successfully applied patch to ${
|
|
61
|
+
message: `Successfully applied patch to ${filePath}`,
|
|
47
62
|
patchPath,
|
|
48
63
|
status: 'success',
|
|
49
64
|
};
|
|
@@ -58,4 +73,4 @@ export const editingTools = {
|
|
|
58
73
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
74
|
}),
|
|
60
75
|
};
|
|
61
|
-
/* eslint-enable camelcase */
|
|
76
|
+
/* eslint-enable camelcase */
|
|
@@ -83,7 +83,7 @@ ${content}`
|
|
|
83
83
|
throw new Error('Pipeline ID is required');
|
|
84
84
|
const client = createGitlabClient();
|
|
85
85
|
const pipeline = await client.Pipelines.show(projectId, pipelineId);
|
|
86
|
-
const jobs = await client.
|
|
86
|
+
const jobs = await client.Jobs.all(projectId, { pipelineId });
|
|
87
87
|
return {
|
|
88
88
|
jobs: jobs.map((j) => ({
|
|
89
89
|
id: j.id,
|
|
@@ -141,11 +141,15 @@ ${content}`
|
|
|
141
141
|
if (!projectId)
|
|
142
142
|
throw new Error('Project ID is required (argument or default config)');
|
|
143
143
|
const client = createGitlabClient();
|
|
144
|
-
const queryOptions = {
|
|
145
|
-
if (args.
|
|
144
|
+
const queryOptions = {};
|
|
145
|
+
if (typeof args.limit === 'number') {
|
|
146
|
+
queryOptions.perPage = args.limit;
|
|
147
|
+
}
|
|
148
|
+
if (args.scope) {
|
|
146
149
|
queryOptions.scope = args.scope;
|
|
150
|
+
}
|
|
147
151
|
const jobs = pipelineId
|
|
148
|
-
? await client.
|
|
152
|
+
? await client.Jobs.all(projectId, { ...queryOptions, pipelineId })
|
|
149
153
|
: await client.Jobs.all(projectId, queryOptions);
|
|
150
154
|
return {
|
|
151
155
|
jobs: jobs.map((j) => ({
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export interface DuoOpsConfig {
|
|
|
8
8
|
bigqueryTable?: string;
|
|
9
9
|
googleProjectId?: string;
|
|
10
10
|
};
|
|
11
|
+
portal?: {
|
|
12
|
+
budgets?: PortalBudgetConfig[];
|
|
13
|
+
};
|
|
11
14
|
runner?: {
|
|
12
15
|
gcpInstanceId?: string;
|
|
13
16
|
gcpProjectId?: string;
|
|
@@ -16,6 +19,13 @@ export interface DuoOpsConfig {
|
|
|
16
19
|
machineType?: string;
|
|
17
20
|
};
|
|
18
21
|
}
|
|
22
|
+
export interface PortalBudgetConfig {
|
|
23
|
+
id: string;
|
|
24
|
+
limit: number;
|
|
25
|
+
name: string;
|
|
26
|
+
period: string;
|
|
27
|
+
unit: string;
|
|
28
|
+
}
|
|
19
29
|
export declare class ConfigManager {
|
|
20
30
|
private configPath;
|
|
21
31
|
constructor();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function requireGcloud(): void;
|
|
2
|
+
export declare function getActiveAccount(): string;
|
|
3
|
+
export declare function detectGcpProject(): string | undefined;
|
|
4
|
+
export declare function validateProjectAccess(project: string): void;
|
|
5
|
+
export declare function enableApis(project: string, apis: string[], log: (msg: string) => void): void;
|
|
6
|
+
export declare function getProjectNumber(project: string): string | undefined;
|
|
7
|
+
export declare function ensureCloudBuildServiceAccount(project: string, log: (msg: string) => void): void;
|