chainlesschain 0.66.0 → 0.81.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.
@@ -380,4 +380,334 @@ export function _resetState() {
380
380
  _runs.clear();
381
381
  _results.clear();
382
382
  _seq = 0;
383
+ _maxConcurrentTests = DEFAULT_MAX_CONCURRENT_TESTS;
384
+ }
385
+
386
+ /* ═══════════════════════════════════════════════════════════════
387
+ * V2 Canonical Surface (Phase 59 — Federation Stress Test)
388
+ * Strictly additive; legacy exports above remain unchanged.
389
+ * ═══════════════════════════════════════════════════════════════ */
390
+
391
+ export const RUN_STATUS_V2 = Object.freeze({
392
+ RUNNING: "running",
393
+ COMPLETE: "complete",
394
+ STOPPED: "stopped",
395
+ FAILED: "failed",
396
+ });
397
+
398
+ export const LEVEL_NAME_V2 = Object.freeze({
399
+ LIGHT: "light",
400
+ MEDIUM: "medium",
401
+ HEAVY: "heavy",
402
+ EXTREME: "extreme",
403
+ });
404
+
405
+ export const BOTTLENECK_KIND_V2 = Object.freeze({
406
+ ERROR_RATE: "error-rate",
407
+ TAIL_LATENCY: "tail-latency",
408
+ RESPONSE_TIME: "response-time",
409
+ THROUGHPUT: "throughput",
410
+ });
411
+
412
+ export const BOTTLENECK_SEVERITY_V2 = Object.freeze({
413
+ LOW: "low",
414
+ MEDIUM: "medium",
415
+ HIGH: "high",
416
+ });
417
+
418
+ const DEFAULT_MAX_CONCURRENT_TESTS = 3;
419
+ let _maxConcurrentTests = DEFAULT_MAX_CONCURRENT_TESTS;
420
+
421
+ export const STRESS_DEFAULT_MAX_CONCURRENT = DEFAULT_MAX_CONCURRENT_TESTS;
422
+
423
+ export function setMaxConcurrentTests(n) {
424
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 1) {
425
+ throw new Error("maxConcurrentTests must be a positive integer");
426
+ }
427
+ _maxConcurrentTests = Math.floor(n);
428
+ return _maxConcurrentTests;
429
+ }
430
+
431
+ export function getMaxConcurrentTests() {
432
+ return _maxConcurrentTests;
433
+ }
434
+
435
+ const _terminalRunStatuses = new Set([
436
+ RUN_STATUS_V2.COMPLETE,
437
+ RUN_STATUS_V2.STOPPED,
438
+ RUN_STATUS_V2.FAILED,
439
+ ]);
440
+
441
+ // Run state machine: running → { complete, stopped, failed }
442
+ const _allowedRunTransitions = new Map([
443
+ [
444
+ RUN_STATUS_V2.RUNNING,
445
+ new Set([
446
+ RUN_STATUS_V2.COMPLETE,
447
+ RUN_STATUS_V2.STOPPED,
448
+ RUN_STATUS_V2.FAILED,
449
+ ]),
450
+ ],
451
+ [RUN_STATUS_V2.COMPLETE, new Set([])],
452
+ [RUN_STATUS_V2.STOPPED, new Set([])],
453
+ [RUN_STATUS_V2.FAILED, new Set([])],
454
+ ]);
455
+
456
+ export function getActiveTestCount() {
457
+ let count = 0;
458
+ for (const r of _runs.values()) {
459
+ if (r.status === RUN_STATUS_V2.RUNNING) count++;
460
+ }
461
+ return count;
462
+ }
463
+
464
+ /**
465
+ * startStressTestV2 — asynchronous lifecycle variant. Creates the run row in
466
+ * RUNNING state without computing metrics; caller is expected to call
467
+ * completeStressTest, stopStressTestV2, or failStressTest afterwards.
468
+ */
469
+ export function startStressTestV2(db, config = {}) {
470
+ const levelName = config.level || LEVEL_NAME_V2.MEDIUM;
471
+ const level = resolveLevel(levelName);
472
+ if (!level) {
473
+ throw new Error(
474
+ `Unknown load level: ${levelName} (known: ${Object.values(LEVEL_NAME_V2).join("/")})`,
475
+ );
476
+ }
477
+
478
+ const concurrency = Number(config.concurrency ?? level.concurrency);
479
+ const requestsPerSecond = Number(
480
+ config.requestsPerSecond ?? level.requestsPerSecond,
481
+ );
482
+ const duration = Number(config.duration ?? level.duration);
483
+ if (concurrency <= 0 || duration <= 0 || requestsPerSecond <= 0) {
484
+ throw new Error("concurrency/duration/requestsPerSecond must all be > 0");
485
+ }
486
+
487
+ const activeCount = getActiveTestCount();
488
+ if (activeCount >= _maxConcurrentTests) {
489
+ throw new Error(
490
+ `Max concurrent stress tests reached: ${activeCount}/${_maxConcurrentTests}`,
491
+ );
492
+ }
493
+
494
+ const testId = crypto.randomUUID();
495
+ const now = Date.now();
496
+
497
+ const run = {
498
+ testId,
499
+ loadLevel: level.name,
500
+ concurrency,
501
+ requestsPerSecond,
502
+ duration,
503
+ status: RUN_STATUS_V2.RUNNING,
504
+ startedAt: now,
505
+ completedAt: null,
506
+ errorMessage: null,
507
+ _seq: ++_seq,
508
+ };
509
+
510
+ _runs.set(testId, run);
511
+ db.prepare(
512
+ `INSERT INTO stress_test_runs (test_id, load_level, concurrency, requests_per_second, duration, status, started_at, completed_at)
513
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
514
+ ).run(
515
+ testId,
516
+ level.name,
517
+ concurrency,
518
+ requestsPerSecond,
519
+ duration,
520
+ run.status,
521
+ now,
522
+ null,
523
+ );
524
+
525
+ const { _seq: _omit, ...rest } = run;
526
+ void _omit;
527
+ return rest;
528
+ }
529
+
530
+ export function completeStressTest(db, testId) {
531
+ const run = _runs.get(testId);
532
+ if (!run) throw new Error(`Stress test not found: ${testId}`);
533
+
534
+ const allowed = _allowedRunTransitions.get(run.status);
535
+ if (!allowed || !allowed.has(RUN_STATUS_V2.COMPLETE)) {
536
+ throw new Error(`Invalid run status transition: ${run.status} → complete`);
537
+ }
538
+
539
+ const seed = _hashSeed(testId);
540
+ const metrics = _synthesizeMetrics(
541
+ {
542
+ concurrency: run.concurrency,
543
+ requestsPerSecond: run.requestsPerSecond,
544
+ duration: run.duration,
545
+ },
546
+ seed,
547
+ );
548
+ const bottlenecks = _deriveBottlenecks(metrics, {
549
+ requestsPerSecond: run.requestsPerSecond,
550
+ });
551
+ const recommendations = _capacityRecommendations(
552
+ metrics,
553
+ { requestsPerSecond: run.requestsPerSecond },
554
+ bottlenecks,
555
+ );
556
+
557
+ const now = Date.now();
558
+ const resultId = crypto.randomUUID();
559
+ const result = {
560
+ resultId,
561
+ testId,
562
+ ...metrics,
563
+ bottlenecks,
564
+ capacityRecommendations: recommendations,
565
+ createdAt: now,
566
+ };
567
+ _results.set(testId, result);
568
+
569
+ db.prepare(
570
+ `INSERT INTO stress_test_results (result_id, test_id, tps, avg_response_time, p50_response_time, p95_response_time, p99_response_time, error_rate, bottlenecks, capacity_recommendations, created_at)
571
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
572
+ ).run(
573
+ resultId,
574
+ testId,
575
+ metrics.tps,
576
+ metrics.avgResponseTime,
577
+ metrics.p50ResponseTime,
578
+ metrics.p95ResponseTime,
579
+ metrics.p99ResponseTime,
580
+ metrics.errorRate,
581
+ JSON.stringify(bottlenecks),
582
+ JSON.stringify(recommendations),
583
+ now,
584
+ );
585
+
586
+ run.status = RUN_STATUS_V2.COMPLETE;
587
+ run.completedAt = now;
588
+ db.prepare(
589
+ `UPDATE stress_test_runs SET status = ?, completed_at = ? WHERE test_id = ?`,
590
+ ).run(RUN_STATUS_V2.COMPLETE, now, testId);
591
+
592
+ const { _seq: _omit, ...rest } = run;
593
+ void _omit;
594
+ return { ...rest, result };
595
+ }
596
+
597
+ export function stopStressTestV2(db, testId) {
598
+ return setRunStatus(db, testId, RUN_STATUS_V2.STOPPED);
599
+ }
600
+
601
+ export function failStressTest(db, testId, errorMessage) {
602
+ return setRunStatus(db, testId, RUN_STATUS_V2.FAILED, { errorMessage });
603
+ }
604
+
605
+ export function setRunStatus(db, testId, newStatus, patch = {}) {
606
+ const run = _runs.get(testId);
607
+ if (!run) throw new Error(`Stress test not found: ${testId}`);
608
+
609
+ const validStatuses = Object.values(RUN_STATUS_V2);
610
+ if (!validStatuses.includes(newStatus)) {
611
+ throw new Error(`Unknown run status: ${newStatus}`);
612
+ }
613
+
614
+ const allowed = _allowedRunTransitions.get(run.status);
615
+ if (!allowed || !allowed.has(newStatus)) {
616
+ throw new Error(
617
+ `Invalid run status transition: ${run.status} → ${newStatus}`,
618
+ );
619
+ }
620
+
621
+ run.status = newStatus;
622
+ if (typeof patch.errorMessage === "string") {
623
+ run.errorMessage = patch.errorMessage;
624
+ }
625
+ if (_terminalRunStatuses.has(newStatus)) {
626
+ run.completedAt = Date.now();
627
+ }
628
+
629
+ db.prepare(
630
+ `UPDATE stress_test_runs SET status = ?, completed_at = ? WHERE test_id = ?`,
631
+ ).run(newStatus, run.completedAt, testId);
632
+
633
+ const { _seq: _omit, ...rest } = run;
634
+ void _omit;
635
+ return rest;
636
+ }
637
+
638
+ /**
639
+ * recommendLevelV2 — suggest the largest pre-defined level whose targetRps is
640
+ * still ≤ the caller's target. Returns `light` for any sub-light target,
641
+ * `extreme` for anything ≥ extreme.
642
+ */
643
+ export function recommendLevelV2(targetRps) {
644
+ if (
645
+ typeof targetRps !== "number" ||
646
+ !Number.isFinite(targetRps) ||
647
+ targetRps <= 0
648
+ ) {
649
+ throw new Error("targetRps must be a positive number");
650
+ }
651
+ const levels = Object.values(LOAD_LEVELS)
652
+ .slice()
653
+ .sort((a, b) => a.requestsPerSecond - b.requestsPerSecond);
654
+ let chosen = levels[0];
655
+ for (const level of levels) {
656
+ if (targetRps >= level.requestsPerSecond) chosen = level;
657
+ }
658
+ return { ...chosen };
659
+ }
660
+
661
+ export function getStressStatsV2() {
662
+ const runs = [..._runs.values()];
663
+ const results = [..._results.values()];
664
+
665
+ const byStatus = {};
666
+ for (const s of Object.values(RUN_STATUS_V2)) byStatus[s] = 0;
667
+ for (const r of runs) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
668
+
669
+ const byLevel = {};
670
+ for (const l of Object.values(LEVEL_NAME_V2)) byLevel[l] = 0;
671
+ for (const r of runs) byLevel[r.loadLevel] = (byLevel[r.loadLevel] || 0) + 1;
672
+
673
+ const byKind = {};
674
+ for (const k of Object.values(BOTTLENECK_KIND_V2)) byKind[k] = 0;
675
+ const bySeverity = {};
676
+ for (const s of Object.values(BOTTLENECK_SEVERITY_V2)) bySeverity[s] = 0;
677
+
678
+ let totalTps = 0;
679
+ let totalP95 = 0;
680
+ let metricSamples = 0;
681
+ let totalBottlenecks = 0;
682
+
683
+ for (const result of results) {
684
+ totalTps += result.tps || 0;
685
+ totalP95 += result.p95ResponseTime || 0;
686
+ metricSamples++;
687
+ for (const b of result.bottlenecks || []) {
688
+ totalBottlenecks++;
689
+ if (byKind[b.kind] !== undefined) byKind[b.kind]++;
690
+ if (bySeverity[b.severity] !== undefined) bySeverity[b.severity]++;
691
+ }
692
+ }
693
+
694
+ return {
695
+ totalTests: runs.length,
696
+ activeTests: getActiveTestCount(),
697
+ maxConcurrentTests: _maxConcurrentTests,
698
+ byStatus,
699
+ byLevel,
700
+ bottlenecks: {
701
+ total: totalBottlenecks,
702
+ byKind,
703
+ bySeverity,
704
+ },
705
+ aggregateMetrics: {
706
+ samples: metricSamples,
707
+ avgTps:
708
+ metricSamples > 0 ? Number((totalTps / metricSamples).toFixed(2)) : 0,
709
+ avgP95:
710
+ metricSamples > 0 ? Number((totalP95 / metricSamples).toFixed(2)) : 0,
711
+ },
712
+ };
383
713
  }
@@ -198,4 +198,367 @@ export function listRuns(filter = {}) {
198
198
  export function _resetState() {
199
199
  _workspaces.clear();
200
200
  _runs.clear();
201
+ _maxConcurrentRuns = DEFAULT_MAX_CONCURRENT_RUNS;
202
+ }
203
+
204
+ /* ═══════════════════════════════════════════════════════════════
205
+ * V2 Canonical Surface (Phase 56 — Terraform Manager)
206
+ * Strictly additive; legacy exports above remain unchanged.
207
+ * ═══════════════════════════════════════════════════════════════ */
208
+
209
+ export const RUN_STATUS_V2 = Object.freeze({
210
+ PENDING: "pending",
211
+ PLANNING: "planning",
212
+ PLANNED: "planned",
213
+ APPLYING: "applying",
214
+ APPLIED: "applied",
215
+ DESTROYING: "destroying",
216
+ DESTROYED: "destroyed",
217
+ ERRORED: "errored",
218
+ CANCELLED: "cancelled",
219
+ });
220
+
221
+ export const RUN_TYPE_V2 = Object.freeze({
222
+ PLAN: "plan",
223
+ APPLY: "apply",
224
+ DESTROY: "destroy",
225
+ });
226
+
227
+ export const WORKSPACE_STATUS_V2 = Object.freeze({
228
+ ACTIVE: "active",
229
+ LOCKED: "locked",
230
+ ARCHIVED: "archived",
231
+ });
232
+
233
+ const DEFAULT_MAX_CONCURRENT_RUNS = 5;
234
+ let _maxConcurrentRuns = DEFAULT_MAX_CONCURRENT_RUNS;
235
+
236
+ export const TERRAFORM_DEFAULT_MAX_CONCURRENT = DEFAULT_MAX_CONCURRENT_RUNS;
237
+
238
+ export function setMaxConcurrentRuns(n) {
239
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 1) {
240
+ throw new Error("maxConcurrentRuns must be a positive integer");
241
+ }
242
+ _maxConcurrentRuns = Math.floor(n);
243
+ return _maxConcurrentRuns;
244
+ }
245
+
246
+ export function getMaxConcurrentRuns() {
247
+ return _maxConcurrentRuns;
248
+ }
249
+
250
+ const _activeRunStatuses = new Set([
251
+ RUN_STATUS_V2.PENDING,
252
+ RUN_STATUS_V2.PLANNING,
253
+ RUN_STATUS_V2.APPLYING,
254
+ RUN_STATUS_V2.DESTROYING,
255
+ ]);
256
+
257
+ const _terminalRunStatuses = new Set([
258
+ RUN_STATUS_V2.APPLIED,
259
+ RUN_STATUS_V2.DESTROYED,
260
+ RUN_STATUS_V2.ERRORED,
261
+ RUN_STATUS_V2.CANCELLED,
262
+ ]);
263
+
264
+ // run-status state machine
265
+ const _allowedRunTransitions = new Map([
266
+ [
267
+ RUN_STATUS_V2.PENDING,
268
+ new Set([
269
+ RUN_STATUS_V2.PLANNING,
270
+ RUN_STATUS_V2.CANCELLED,
271
+ RUN_STATUS_V2.ERRORED,
272
+ ]),
273
+ ],
274
+ [
275
+ RUN_STATUS_V2.PLANNING,
276
+ new Set([
277
+ RUN_STATUS_V2.PLANNED,
278
+ RUN_STATUS_V2.ERRORED,
279
+ RUN_STATUS_V2.CANCELLED,
280
+ ]),
281
+ ],
282
+ [
283
+ RUN_STATUS_V2.PLANNED,
284
+ new Set([
285
+ RUN_STATUS_V2.APPLYING,
286
+ RUN_STATUS_V2.DESTROYING,
287
+ RUN_STATUS_V2.CANCELLED,
288
+ RUN_STATUS_V2.ERRORED,
289
+ ]),
290
+ ],
291
+ [
292
+ RUN_STATUS_V2.APPLYING,
293
+ new Set([RUN_STATUS_V2.APPLIED, RUN_STATUS_V2.ERRORED]),
294
+ ],
295
+ [
296
+ RUN_STATUS_V2.DESTROYING,
297
+ new Set([RUN_STATUS_V2.DESTROYED, RUN_STATUS_V2.ERRORED]),
298
+ ],
299
+ [RUN_STATUS_V2.APPLIED, new Set([])],
300
+ [RUN_STATUS_V2.DESTROYED, new Set([])],
301
+ [RUN_STATUS_V2.ERRORED, new Set([])],
302
+ [RUN_STATUS_V2.CANCELLED, new Set([])],
303
+ ]);
304
+
305
+ // workspace-status state machine
306
+ const _allowedWorkspaceTransitions = new Map([
307
+ [
308
+ WORKSPACE_STATUS_V2.ACTIVE,
309
+ new Set([WORKSPACE_STATUS_V2.LOCKED, WORKSPACE_STATUS_V2.ARCHIVED]),
310
+ ],
311
+ [
312
+ WORKSPACE_STATUS_V2.LOCKED,
313
+ new Set([WORKSPACE_STATUS_V2.ACTIVE, WORKSPACE_STATUS_V2.ARCHIVED]),
314
+ ],
315
+ [WORKSPACE_STATUS_V2.ARCHIVED, new Set([WORKSPACE_STATUS_V2.ACTIVE])],
316
+ ]);
317
+
318
+ export function createWorkspaceV2(db, options = {}) {
319
+ const {
320
+ name,
321
+ description,
322
+ terraformVersion,
323
+ workingDirectory,
324
+ autoApply,
325
+ variables,
326
+ providers,
327
+ } = options;
328
+
329
+ if (!name) throw new Error("Workspace name is required");
330
+
331
+ // Unique name check
332
+ for (const existing of _workspaces.values()) {
333
+ if (existing.name === name) {
334
+ throw new Error(`Workspace name already exists: ${name}`);
335
+ }
336
+ }
337
+
338
+ return createWorkspace(db, name, {
339
+ description,
340
+ terraformVersion,
341
+ workingDirectory,
342
+ autoApply,
343
+ variables,
344
+ providers,
345
+ });
346
+ }
347
+
348
+ export function setWorkspaceStatus(db, workspaceId, newStatus) {
349
+ const workspace = _workspaces.get(workspaceId);
350
+ if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
351
+
352
+ const validStatuses = Object.values(WORKSPACE_STATUS_V2);
353
+ if (!validStatuses.includes(newStatus)) {
354
+ throw new Error(`Unknown workspace status: ${newStatus}`);
355
+ }
356
+
357
+ const allowed = _allowedWorkspaceTransitions.get(workspace.status);
358
+ if (!allowed || !allowed.has(newStatus)) {
359
+ throw new Error(
360
+ `Invalid workspace status transition: ${workspace.status} → ${newStatus}`,
361
+ );
362
+ }
363
+
364
+ workspace.status = newStatus;
365
+ db.prepare(`UPDATE terraform_workspaces SET status = ? WHERE id = ?`).run(
366
+ newStatus,
367
+ workspaceId,
368
+ );
369
+
370
+ return { workspaceId, status: newStatus };
371
+ }
372
+
373
+ export function archiveWorkspace(db, workspaceId) {
374
+ return setWorkspaceStatus(db, workspaceId, WORKSPACE_STATUS_V2.ARCHIVED);
375
+ }
376
+
377
+ function _countActiveRuns() {
378
+ let count = 0;
379
+ for (const r of _runs.values()) {
380
+ if (_activeRunStatuses.has(r.status)) count++;
381
+ }
382
+ return count;
383
+ }
384
+
385
+ export function planRunV2(db, options = {}) {
386
+ const { workspaceId, runType, triggeredBy } = options;
387
+ const workspace = _workspaces.get(workspaceId);
388
+ if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
389
+
390
+ const validRunTypes = Object.values(RUN_TYPE_V2);
391
+ const effectiveType = runType || RUN_TYPE_V2.PLAN;
392
+ if (!validRunTypes.includes(effectiveType)) {
393
+ throw new Error(`Unknown run type: ${effectiveType}`);
394
+ }
395
+
396
+ if (workspace.status === WORKSPACE_STATUS_V2.ARCHIVED) {
397
+ throw new Error(`Cannot plan run on archived workspace: ${workspaceId}`);
398
+ }
399
+
400
+ const activeCount = _countActiveRuns();
401
+ if (activeCount >= _maxConcurrentRuns) {
402
+ throw new Error(
403
+ `Max concurrent runs reached: ${activeCount}/${_maxConcurrentRuns}`,
404
+ );
405
+ }
406
+
407
+ const id = crypto.randomUUID();
408
+ const now = new Date().toISOString();
409
+
410
+ const run = {
411
+ id,
412
+ workspaceId,
413
+ runType: effectiveType,
414
+ status: RUN_STATUS_V2.PENDING,
415
+ planOutput: null,
416
+ applyOutput: null,
417
+ resourcesAdded: 0,
418
+ resourcesChanged: 0,
419
+ resourcesDestroyed: 0,
420
+ triggeredBy: triggeredBy || "manual",
421
+ startedAt: now,
422
+ completedAt: null,
423
+ errorMessage: null,
424
+ createdAt: now,
425
+ };
426
+
427
+ _runs.set(id, run);
428
+
429
+ db.prepare(
430
+ `INSERT INTO terraform_runs (id, workspace_id, run_type, status, plan_output, apply_output, resources_added, resources_changed, resources_destroyed, triggered_by, started_at, completed_at, error_message, created_at)
431
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
432
+ ).run(
433
+ id,
434
+ workspaceId,
435
+ effectiveType,
436
+ run.status,
437
+ null,
438
+ null,
439
+ 0,
440
+ 0,
441
+ 0,
442
+ run.triggeredBy,
443
+ now,
444
+ null,
445
+ null,
446
+ now,
447
+ );
448
+
449
+ return run;
450
+ }
451
+
452
+ export function setRunStatus(db, runId, newStatus, patch = {}) {
453
+ const run = _runs.get(runId);
454
+ if (!run) throw new Error(`Run not found: ${runId}`);
455
+
456
+ const validStatuses = Object.values(RUN_STATUS_V2);
457
+ if (!validStatuses.includes(newStatus)) {
458
+ throw new Error(`Unknown run status: ${newStatus}`);
459
+ }
460
+
461
+ const allowed = _allowedRunTransitions.get(run.status);
462
+ if (!allowed || !allowed.has(newStatus)) {
463
+ throw new Error(
464
+ `Invalid run status transition: ${run.status} → ${newStatus}`,
465
+ );
466
+ }
467
+
468
+ run.status = newStatus;
469
+ if (typeof patch.planOutput === "string") run.planOutput = patch.planOutput;
470
+ if (typeof patch.applyOutput === "string")
471
+ run.applyOutput = patch.applyOutput;
472
+ if (typeof patch.resourcesAdded === "number")
473
+ run.resourcesAdded = patch.resourcesAdded;
474
+ if (typeof patch.resourcesChanged === "number")
475
+ run.resourcesChanged = patch.resourcesChanged;
476
+ if (typeof patch.resourcesDestroyed === "number")
477
+ run.resourcesDestroyed = patch.resourcesDestroyed;
478
+ if (typeof patch.errorMessage === "string")
479
+ run.errorMessage = patch.errorMessage;
480
+ if (_terminalRunStatuses.has(newStatus)) {
481
+ run.completedAt = new Date().toISOString();
482
+ }
483
+
484
+ db.prepare(
485
+ `UPDATE terraform_runs SET status = ?, plan_output = ?, apply_output = ?, resources_added = ?, resources_changed = ?, resources_destroyed = ?, error_message = ?, completed_at = ? WHERE id = ?`,
486
+ ).run(
487
+ run.status,
488
+ run.planOutput,
489
+ run.applyOutput,
490
+ run.resourcesAdded,
491
+ run.resourcesChanged,
492
+ run.resourcesDestroyed,
493
+ run.errorMessage,
494
+ run.completedAt,
495
+ runId,
496
+ );
497
+
498
+ // Bump workspace state version on terminal apply/destroy
499
+ if (
500
+ newStatus === RUN_STATUS_V2.APPLIED ||
501
+ newStatus === RUN_STATUS_V2.DESTROYED
502
+ ) {
503
+ const workspace = _workspaces.get(run.workspaceId);
504
+ if (workspace) {
505
+ workspace.stateVersion++;
506
+ workspace.lastRunId = runId;
507
+ workspace.lastRunAt = new Date().toISOString();
508
+ }
509
+ }
510
+
511
+ return run;
512
+ }
513
+
514
+ export function cancelRun(db, runId) {
515
+ return setRunStatus(db, runId, RUN_STATUS_V2.CANCELLED);
516
+ }
517
+
518
+ export function failRun(db, runId, errorMessage) {
519
+ return setRunStatus(db, runId, RUN_STATUS_V2.ERRORED, { errorMessage });
520
+ }
521
+
522
+ export function getActiveRunCount() {
523
+ return _countActiveRuns();
524
+ }
525
+
526
+ export function getTerraformStatsV2() {
527
+ const workspaces = [..._workspaces.values()];
528
+ const runs = [..._runs.values()];
529
+
530
+ const wsByStatus = {};
531
+ for (const s of Object.values(WORKSPACE_STATUS_V2)) wsByStatus[s] = 0;
532
+ for (const w of workspaces)
533
+ wsByStatus[w.status] = (wsByStatus[w.status] || 0) + 1;
534
+
535
+ const runsByStatus = {};
536
+ for (const s of Object.values(RUN_STATUS_V2)) runsByStatus[s] = 0;
537
+ for (const r of runs)
538
+ runsByStatus[r.status] = (runsByStatus[r.status] || 0) + 1;
539
+
540
+ const runsByType = {};
541
+ for (const t of Object.values(RUN_TYPE_V2)) runsByType[t] = 0;
542
+ for (const r of runs)
543
+ runsByType[r.runType] = (runsByType[r.runType] || 0) + 1;
544
+
545
+ const totalResources = runs.reduce(
546
+ (acc, r) => ({
547
+ added: acc.added + (r.resourcesAdded || 0),
548
+ changed: acc.changed + (r.resourcesChanged || 0),
549
+ destroyed: acc.destroyed + (r.resourcesDestroyed || 0),
550
+ }),
551
+ { added: 0, changed: 0, destroyed: 0 },
552
+ );
553
+
554
+ return {
555
+ totalWorkspaces: workspaces.length,
556
+ totalRuns: runs.length,
557
+ activeRuns: _countActiveRuns(),
558
+ maxConcurrentRuns: _maxConcurrentRuns,
559
+ workspacesByStatus: wsByStatus,
560
+ runsByStatus,
561
+ runsByType,
562
+ totalResources,
563
+ };
201
564
  }