duoops 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +151 -63
  2. package/data/aws_machine_power_profiles.json +54 -0
  3. package/data/cpu_physical_specs.json +105 -0
  4. package/data/cpu_power_profiles.json +275 -0
  5. package/data/gcp_machine_power_profiles.json +1802 -0
  6. package/data/runtime-pue-mappings.json +183 -0
  7. package/dist/commands/ask.d.ts +3 -0
  8. package/dist/commands/ask.js +9 -3
  9. package/dist/commands/autofix-ci.d.ts +13 -0
  10. package/dist/commands/autofix-ci.js +114 -0
  11. package/dist/commands/autofix.d.ts +5 -0
  12. package/dist/commands/autofix.js +11 -0
  13. package/dist/commands/config.d.ts +10 -0
  14. package/dist/commands/config.js +47 -0
  15. package/dist/commands/init.js +50 -27
  16. package/dist/commands/mcp/deploy.d.ts +13 -0
  17. package/dist/commands/mcp/deploy.js +139 -0
  18. package/dist/commands/measure/calculate.js +2 -2
  19. package/dist/commands/portal.js +428 -11
  20. package/dist/lib/ai/agent.d.ts +6 -2
  21. package/dist/lib/ai/agent.js +51 -57
  22. package/dist/lib/ai/tools/editing.js +28 -13
  23. package/dist/lib/ai/tools/gitlab.d.ts +4 -0
  24. package/dist/lib/ai/tools/gitlab.js +166 -11
  25. package/dist/lib/ai/tools/measure.js +7 -3
  26. package/dist/lib/ai/tools/types.d.ts +3 -0
  27. package/dist/lib/ai/tools/types.js +1 -0
  28. package/dist/lib/config.d.ts +10 -0
  29. package/dist/lib/gcloud.d.ts +7 -0
  30. package/dist/lib/gcloud.js +105 -0
  31. package/dist/lib/gitlab/pipelines-service.d.ts +23 -0
  32. package/dist/lib/gitlab/pipelines-service.js +146 -0
  33. package/dist/lib/gitlab/runner-service.d.ts +11 -0
  34. package/dist/lib/gitlab/runner-service.js +15 -0
  35. package/dist/lib/portal/settings.d.ts +3 -0
  36. package/dist/lib/portal/settings.js +48 -0
  37. package/dist/lib/scaffold.d.ts +5 -0
  38. package/dist/lib/scaffold.js +32 -0
  39. package/dist/portal/assets/HomeDashboard-DlkwSyKx.js +1 -0
  40. package/dist/portal/assets/JobDetailsDrawer-7kXXMSH8.js +1 -0
  41. package/dist/portal/assets/JobsDashboard-D4pNc9TM.js +1 -0
  42. package/dist/portal/assets/MetricsDashboard-BcgzvzBz.js +1 -0
  43. package/dist/portal/assets/PipelinesDashboard-BNrSM9GB.js +1 -0
  44. package/dist/portal/assets/allPaths-CXDKahbk.js +1 -0
  45. package/dist/portal/assets/allPathsLoader-BF5PAx2c.js +2 -0
  46. package/dist/portal/assets/cache-YerT0Slh.js +6 -0
  47. package/dist/portal/assets/core-Cz8f3oSB.js +19 -0
  48. package/dist/portal/assets/{index-B6bzT1Vv.js → index-B9sNUqEC.js} +1 -1
  49. package/dist/portal/assets/index-BWa_E8Y7.css +1 -0
  50. package/dist/portal/assets/index-Bp4RqK05.js +1 -0
  51. package/dist/portal/assets/index-DW6Qp0d6.js +64 -0
  52. package/dist/portal/assets/index-Uc4Xhv31.js +1 -0
  53. package/dist/portal/assets/progressBar-C4SmnGeZ.js +1 -0
  54. package/dist/portal/assets/splitPathsBySizeLoader-C-T9_API.js +1 -0
  55. package/dist/portal/index.html +2 -2
  56. package/oclif.manifest.json +282 -93
  57. package/package.json +2 -1
  58. package/templates/.gitlab/duo/flows/duoops.yaml +114 -0
  59. package/templates/agents/agent.yml +45 -0
  60. package/templates/duoops-autofix-component.yml +52 -0
  61. package/templates/flows/flow.yml +283 -0
  62. package/dist/portal/assets/MetricsDashboard-DIsoz4Sl.js +0 -71
  63. package/dist/portal/assets/index-BP8FwWqA.css +0 -1
  64. package/dist/portal/assets/index-DkVG3jel.js +0 -70
@@ -0,0 +1,139 @@
1
+ import { confirm, input } from '@inquirer/prompts';
2
+ import { Command, Flags } from '@oclif/core';
3
+ import { bold, cyan, gray, green } from 'kleur/colors';
4
+ import { execSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { configManager } from '../../lib/config.js';
9
+ import { detectGcpProject, enableApis, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
10
+ export default class McpDeploy extends Command {
11
+ static description = 'Deploy the DuoOps MCP server to Google Cloud Run';
12
+ static flags = {
13
+ 'bq-dataset': Flags.string({ description: 'BigQuery Dataset ID' }),
14
+ 'bq-table': Flags.string({ description: 'BigQuery Table ID' }),
15
+ 'gcp-project': Flags.string({ char: 'p', description: 'Google Cloud Project ID' }),
16
+ 'gitlab-url': Flags.string({ default: 'https://gitlab.com', description: 'GitLab instance URL' }),
17
+ region: Flags.string({ char: 'r', default: 'us-central1', description: 'Cloud Run region' }),
18
+ 'source': Flags.string({ default: '', description: 'Path to MCP server source directory' }),
19
+ };
20
+ async run() {
21
+ const { flags } = await this.parse(McpDeploy);
22
+ const config = configManager.get();
23
+ const gcpProject = flags['gcp-project'] || config.measure?.googleProjectId || await input({
24
+ default: detectGcpProject(),
25
+ message: 'Google Cloud Project ID',
26
+ });
27
+ const bqDataset = flags['bq-dataset'] || config.measure?.bigqueryDataset || await input({
28
+ default: 'measure_data',
29
+ message: 'BigQuery Dataset',
30
+ });
31
+ const bqTable = flags['bq-table'] || config.measure?.bigqueryTable || await input({
32
+ default: 'emissions',
33
+ message: 'BigQuery Table',
34
+ });
35
+ const region = flags.region || await input({
36
+ default: 'us-central1',
37
+ message: 'Cloud Run region',
38
+ });
39
+ const gitlabUrl = flags['gitlab-url'] || config.gitlabUrl || await input({
40
+ default: 'https://gitlab.com',
41
+ message: 'GitLab instance URL',
42
+ });
43
+ if (!gcpProject) {
44
+ this.error('Google Cloud Project ID is required.');
45
+ }
46
+ this.log(bold('Deploying DuoOps MCP Server...'));
47
+ this.log(gray(`Project: ${gcpProject}`));
48
+ this.log(gray(`Region: ${region}`));
49
+ this.log(gray(`Dataset: ${bqDataset}.${bqTable}`));
50
+ try {
51
+ requireGcloud();
52
+ }
53
+ catch (error) {
54
+ this.error(error.message);
55
+ }
56
+ const account = getActiveAccount();
57
+ this.log(gray(`Account: ${account}`));
58
+ try {
59
+ validateProjectAccess(gcpProject);
60
+ this.log(green(` ✓ Verified access to ${gcpProject}`));
61
+ }
62
+ catch (error) {
63
+ this.error(error.message);
64
+ }
65
+ this.log(gray('\nEnabling required GCP APIs...'));
66
+ try {
67
+ enableApis(gcpProject, [
68
+ 'run.googleapis.com',
69
+ 'artifactregistry.googleapis.com',
70
+ 'cloudbuild.googleapis.com',
71
+ ], (msg) => this.log(gray(msg)));
72
+ }
73
+ catch (error) {
74
+ this.error(error.message);
75
+ }
76
+ this.log(gray('\nConfiguring Cloud Build service account...'));
77
+ ensureCloudBuildServiceAccount(gcpProject, (msg) => this.log(gray(msg)));
78
+ const mcpServerDir = flags.source || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../mcp-server');
79
+ if (!fs.existsSync(path.join(mcpServerDir, 'Dockerfile'))) {
80
+ this.error(`MCP server source not found at ${mcpServerDir}. Provide --source flag or run from the DuoOps repo.`);
81
+ }
82
+ const serviceName = 'duoops-mcp';
83
+ const cmd = `gcloud run deploy ${serviceName} \
84
+ --source ${mcpServerDir} \
85
+ --project ${gcpProject} \
86
+ --region ${region} \
87
+ --platform managed \
88
+ --allow-unauthenticated \
89
+ --set-env-vars "GCP_PROJECT_ID=${gcpProject},BQ_DATASET=${bqDataset},BQ_TABLE=${bqTable},GITLAB_URL=${gitlabUrl}" \
90
+ --format="value(status.url)"`;
91
+ this.log(gray(`\nBuilding and deploying from ${mcpServerDir}...`));
92
+ this.log(gray('(This uses Cloud Build and may take 2-3 minutes)\n'));
93
+ let serviceUrl = '';
94
+ try {
95
+ serviceUrl = execSync(cmd, { encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] }).trim();
96
+ }
97
+ catch (error) {
98
+ const err = error;
99
+ this.error(`Deployment failed: ${err.stdout || ''}\n${err.stderr || error.message}`);
100
+ }
101
+ if (!serviceUrl) {
102
+ this.error('Deployment succeeded but failed to retrieve service URL.');
103
+ }
104
+ this.log(green(`\n✔ Successfully deployed to: ${bold(serviceUrl)}`));
105
+ this.log(gray('Running health check...'));
106
+ try {
107
+ const health = execSync(`curl -sf ${serviceUrl}/health`, { encoding: 'utf8', timeout: 10_000 }).trim();
108
+ this.log(green(` ✓ ${health}`));
109
+ }
110
+ catch {
111
+ this.warn('Health check failed — server may still be starting up. Try: curl ' + serviceUrl + '/health');
112
+ }
113
+ const mcpConfig = {
114
+ mcpServers: {
115
+ "duoops-carbon": {
116
+ approvedTools: true,
117
+ type: "http",
118
+ url: `${serviceUrl}/mcp`
119
+ }
120
+ }
121
+ };
122
+ const configStr = JSON.stringify(mcpConfig, null, 2);
123
+ this.log(bold('\nMCP Configuration:'));
124
+ this.log(cyan(configStr));
125
+ const writeConfig = await confirm({
126
+ default: true,
127
+ message: 'Write mcp.json to .gitlab/duo/mcp.json?',
128
+ });
129
+ if (writeConfig) {
130
+ const configDir = path.join(process.cwd(), '.gitlab', 'duo');
131
+ const configPath = path.join(configDir, 'mcp.json');
132
+ if (!fs.existsSync(configDir)) {
133
+ fs.mkdirSync(configDir, { recursive: true });
134
+ }
135
+ fs.writeFileSync(configPath, configStr);
136
+ this.log(green(`Wrote configuration to ${configPath}`));
137
+ }
138
+ }
139
+ }
@@ -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/carbon
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, '../../../../data');
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');
@@ -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: 3000,
21
+ default: 58_327,
18
22
  description: 'Port to run the portal on',
19
23
  }),
20
24
  };
@@ -28,13 +32,13 @@ export default class Portal extends Command {
28
32
  /* eslint-disable max-depth -- stream consumer adds necessary nesting */
29
33
  app.post('/api/chat', async (req, res) => {
30
34
  try {
31
- const { messages } = req.body;
35
+ const { messages, projectId } = req.body;
32
36
  if (!messages || !Array.isArray(messages)) {
33
37
  res.status(400).json({ error: 'Messages array is required' });
34
38
  return;
35
39
  }
36
40
  const modelMessages = await convertToModelMessages(messages);
37
- const result = streamAgent(modelMessages);
41
+ const result = streamAgent(modelMessages, { projectId });
38
42
  const response = result.toUIMessageStreamResponse();
39
43
  res.status(response.status);
40
44
  for (const [k, v] of response.headers.entries())
@@ -73,18 +77,21 @@ export default class Portal extends Command {
73
77
  res.json([]);
74
78
  return;
75
79
  }
76
- this.error(String(error), { exit: false });
77
- res.status(500).json({ error: String(error) });
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) => {
81
86
  try {
87
+ const config = configManager.get();
82
88
  const { projectId } = req.query;
83
- if (!projectId) {
84
- res.status(400).json({ error: 'Project ID is required' });
89
+ const targetProjectId = projectId ? String(projectId) : config.defaultProjectId;
90
+ if (!targetProjectId) {
91
+ res.status(400).json({ error: 'Project ID is required and no default is configured' });
85
92
  return;
86
93
  }
87
- const metrics = await fetchCarbonMetrics(String(projectId), 50);
94
+ const metrics = await fetchCarbonMetrics(targetProjectId, 50);
88
95
  res.json(metrics);
89
96
  }
90
97
  catch (error) {
@@ -106,6 +113,183 @@ export default class Portal extends Command {
106
113
  bigqueryActive,
107
114
  });
108
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
+ });
109
293
  // Serve Static Files (Frontend)
110
294
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
111
295
  // Try resolving relative to the compiled command file (dist/commands/portal.js)
@@ -132,8 +316,241 @@ export default class Portal extends Command {
132
316
  });
133
317
  app.listen(port, async () => {
134
318
  const url = `http://localhost:${port}`;
135
- this.log(`🚀 DuoOps Portal running at ${url}`);
136
- await open(url);
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);
331
+ });
332
+ }
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',
137
508
  });
138
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.`;
139
556
  }
@@ -1,6 +1,10 @@
1
1
  import type { ModelMessage } from 'ai';
2
- export declare const runAgent: (input: ModelMessage[] | string) => Promise<string>;
2
+ export declare const runAgent: (input: ModelMessage[] | string, context?: {
3
+ projectId?: string;
4
+ }) => Promise<string>;
3
5
  /** Same as runAgent but returns a streamText result for UI streaming (tool steps, etc.). */
4
- export declare function streamAgent(messages: ModelMessage[]): {
6
+ export declare function streamAgent(messages: ModelMessage[], context?: {
7
+ projectId?: string;
8
+ }): {
5
9
  toUIMessageStreamResponse(): Response;
6
10
  };