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.
Files changed (55) 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/autofix-ci.d.ts +13 -0
  8. package/dist/commands/autofix-ci.js +114 -0
  9. package/dist/commands/autofix.d.ts +5 -0
  10. package/dist/commands/autofix.js +11 -0
  11. package/dist/commands/init.js +104 -33
  12. package/dist/commands/mcp/deploy.d.ts +13 -0
  13. package/dist/commands/mcp/deploy.js +139 -0
  14. package/dist/commands/measure/calculate.js +2 -2
  15. package/dist/commands/portal.js +421 -6
  16. package/dist/lib/ai/agent.js +1 -0
  17. package/dist/lib/ai/tools/editing.js +28 -13
  18. package/dist/lib/ai/tools/gitlab.js +8 -4
  19. package/dist/lib/config.d.ts +10 -0
  20. package/dist/lib/gcloud.d.ts +7 -0
  21. package/dist/lib/gcloud.js +105 -0
  22. package/dist/lib/gitlab/pipelines-service.d.ts +23 -0
  23. package/dist/lib/gitlab/pipelines-service.js +146 -0
  24. package/dist/lib/gitlab/runner-service.d.ts +11 -0
  25. package/dist/lib/gitlab/runner-service.js +15 -0
  26. package/dist/lib/portal/settings.d.ts +3 -0
  27. package/dist/lib/portal/settings.js +48 -0
  28. package/dist/lib/scaffold.d.ts +5 -0
  29. package/dist/lib/scaffold.js +32 -0
  30. package/dist/portal/assets/HomeDashboard-DlkwSyKx.js +1 -0
  31. package/dist/portal/assets/JobDetailsDrawer-7kXXMSH8.js +1 -0
  32. package/dist/portal/assets/JobsDashboard-D4pNc9TM.js +1 -0
  33. package/dist/portal/assets/MetricsDashboard-BcgzvzBz.js +1 -0
  34. package/dist/portal/assets/PipelinesDashboard-BNrSM9GB.js +1 -0
  35. package/dist/portal/assets/allPaths-CXDKahbk.js +1 -0
  36. package/dist/portal/assets/allPathsLoader-BF5PAx2c.js +2 -0
  37. package/dist/portal/assets/cache-YerT0Slh.js +6 -0
  38. package/dist/portal/assets/core-Cz8f3oSB.js +19 -0
  39. package/dist/portal/assets/{index-C54ZhVUo.js → index-B9sNUqEC.js} +1 -1
  40. package/dist/portal/assets/index-BWa_E8Y7.css +1 -0
  41. package/dist/portal/assets/index-Bp4RqK05.js +1 -0
  42. package/dist/portal/assets/index-DW6Qp0d6.js +64 -0
  43. package/dist/portal/assets/index-Uc4Xhv31.js +1 -0
  44. package/dist/portal/assets/progressBar-C4SmnGeZ.js +1 -0
  45. package/dist/portal/assets/splitPathsBySizeLoader-C-T9_API.js +1 -0
  46. package/dist/portal/index.html +2 -2
  47. package/oclif.manifest.json +147 -2
  48. package/package.json +2 -1
  49. package/templates/.gitlab/duo/flows/duoops.yaml +114 -0
  50. package/templates/agents/agent.yml +45 -0
  51. package/templates/duoops-autofix-component.yml +52 -0
  52. package/templates/flows/flow.yml +283 -0
  53. package/dist/portal/assets/MetricsDashboard-Bnj-jtu6.js +0 -27
  54. package/dist/portal/assets/index-B1SGDQNX.css +0 -1
  55. 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/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
  };
@@ -73,8 +77,9 @@ 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) => {
@@ -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
- this.log(`🚀 DuoOps Portal running at ${url}`);
138
- 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);
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
+ }
@@ -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.object({
12
- filePath: z.string().describe('Relative path to the file to edit'),
13
- newString: z.string().describe('The new content to replace with'),
14
- oldString: z.string().describe('The exact existing content to search for and replace'),
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 fullPath = path.resolve(process.cwd(), args.filePath);
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: ${args.filePath}` };
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(args.oldString)) {
29
- return { error: `Could not find exact match for oldString in ${args.filePath}. Please read the file again to ensure accuracy.` };
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(args.oldString, args.newString);
46
+ const newContent = content.replaceAll(oldString, newString);
32
47
  // Generate Unified Diff
33
48
  // createPatch(fileName, oldStr, newStr, oldHeader, newHeader)
34
- const patchContent = createPatch(args.filePath, content, newContent);
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(args.filePath)}-${timestamp}.patch`;
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 ${args.filePath}`,
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.Pipelines.showJobs(projectId, pipelineId);
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 = { perPage: args.limit };
145
- if (args.scope)
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.Pipelines.showJobs(projectId, pipelineId, queryOptions)
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) => ({
@@ -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;