@orbytautomation/engine 0.8.4 → 0.9.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 (100) hide show
  1. package/README.md +127 -108
  2. package/dist/core/EngineConfig.d.ts +1 -1
  3. package/dist/core/EngineConfig.d.ts.map +1 -1
  4. package/dist/core/EngineConfig.js +55 -0
  5. package/dist/core/EngineConfig.js.map +1 -1
  6. package/dist/core/OrbytEngine.d.ts +27 -1
  7. package/dist/core/OrbytEngine.d.ts.map +1 -1
  8. package/dist/core/OrbytEngine.js +471 -5
  9. package/dist/core/OrbytEngine.js.map +1 -1
  10. package/dist/distributed/DistributedStepWorker.d.ts +40 -0
  11. package/dist/distributed/DistributedStepWorker.d.ts.map +1 -0
  12. package/dist/distributed/DistributedStepWorker.js +96 -0
  13. package/dist/distributed/DistributedStepWorker.js.map +1 -0
  14. package/dist/distributed/DistributedWorkflowOrchestrator.d.ts +51 -0
  15. package/dist/distributed/DistributedWorkflowOrchestrator.d.ts.map +1 -0
  16. package/dist/distributed/DistributedWorkflowOrchestrator.js +430 -0
  17. package/dist/distributed/DistributedWorkflowOrchestrator.js.map +1 -0
  18. package/dist/distributed/FileDistributedJobQueue.d.ts +29 -0
  19. package/dist/distributed/FileDistributedJobQueue.d.ts.map +1 -0
  20. package/dist/distributed/FileDistributedJobQueue.js +170 -0
  21. package/dist/distributed/FileDistributedJobQueue.js.map +1 -0
  22. package/dist/distributed/InMemoryDistributedJobQueue.d.ts +26 -0
  23. package/dist/distributed/InMemoryDistributedJobQueue.d.ts.map +1 -0
  24. package/dist/distributed/InMemoryDistributedJobQueue.js +130 -0
  25. package/dist/distributed/InMemoryDistributedJobQueue.js.map +1 -0
  26. package/dist/distributed/index.d.ts +5 -0
  27. package/dist/distributed/index.d.ts.map +1 -0
  28. package/dist/distributed/index.js +5 -0
  29. package/dist/distributed/index.js.map +1 -0
  30. package/dist/errors/FieldRegistry.d.ts +6 -2
  31. package/dist/errors/FieldRegistry.d.ts.map +1 -1
  32. package/dist/errors/FieldRegistry.js +11 -0
  33. package/dist/errors/FieldRegistry.js.map +1 -1
  34. package/dist/execution/ExecutionEngine.d.ts.map +1 -1
  35. package/dist/execution/ExecutionEngine.js +2 -1
  36. package/dist/execution/ExecutionEngine.js.map +1 -1
  37. package/dist/execution/InternalExecutionContext.d.ts.map +1 -1
  38. package/dist/execution/InternalExecutionContext.js +3 -1
  39. package/dist/execution/InternalExecutionContext.js.map +1 -1
  40. package/dist/execution/WorkflowExecutor.d.ts +5 -0
  41. package/dist/execution/WorkflowExecutor.d.ts.map +1 -1
  42. package/dist/execution/WorkflowExecutor.js +195 -7
  43. package/dist/execution/WorkflowExecutor.js.map +1 -1
  44. package/dist/explanation/ExplanationGenerator.d.ts.map +1 -1
  45. package/dist/explanation/ExplanationGenerator.js +6 -0
  46. package/dist/explanation/ExplanationGenerator.js.map +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/parser/SchemaValidator.d.ts +13 -0
  52. package/dist/parser/SchemaValidator.d.ts.map +1 -1
  53. package/dist/parser/SchemaValidator.js +175 -1
  54. package/dist/parser/SchemaValidator.js.map +1 -1
  55. package/dist/parser/WorkflowParser.d.ts +5 -0
  56. package/dist/parser/WorkflowParser.d.ts.map +1 -1
  57. package/dist/parser/WorkflowParser.js +20 -0
  58. package/dist/parser/WorkflowParser.js.map +1 -1
  59. package/dist/scheduling/JobScheduler.d.ts +12 -0
  60. package/dist/scheduling/JobScheduler.d.ts.map +1 -1
  61. package/dist/scheduling/JobScheduler.js +136 -20
  62. package/dist/scheduling/JobScheduler.js.map +1 -1
  63. package/dist/scheduling/Scheduler.d.ts +3 -0
  64. package/dist/scheduling/Scheduler.d.ts.map +1 -1
  65. package/dist/scheduling/Scheduler.js +3 -0
  66. package/dist/scheduling/Scheduler.js.map +1 -1
  67. package/dist/scheduling/workers/workflow-worker.js +59 -3
  68. package/dist/scheduling/workers/workflow-worker.js.map +1 -1
  69. package/dist/storage/CheckpointStore.d.ts +59 -0
  70. package/dist/storage/CheckpointStore.d.ts.map +1 -0
  71. package/dist/storage/CheckpointStore.js +62 -0
  72. package/dist/storage/CheckpointStore.js.map +1 -0
  73. package/dist/storage/index.d.ts +1 -0
  74. package/dist/storage/index.d.ts.map +1 -1
  75. package/dist/storage/index.js +1 -0
  76. package/dist/storage/index.js.map +1 -1
  77. package/dist/testing/integration/distributed/distributed-smoke.d.ts +3 -0
  78. package/dist/testing/integration/distributed/distributed-smoke.d.ts.map +1 -0
  79. package/dist/testing/integration/distributed/distributed-smoke.js +80 -0
  80. package/dist/testing/integration/distributed/distributed-smoke.js.map +1 -0
  81. package/dist/types/core-types.d.ts +278 -1
  82. package/dist/types/core-types.d.ts.map +1 -1
  83. package/dist/types/core-types.js.map +1 -1
  84. package/dist/usage/FileSpoolUsageCollector.d.ts +74 -0
  85. package/dist/usage/FileSpoolUsageCollector.d.ts.map +1 -0
  86. package/dist/usage/FileSpoolUsageCollector.js +225 -0
  87. package/dist/usage/FileSpoolUsageCollector.js.map +1 -0
  88. package/dist/usage/NoOpUsageCollector.d.ts +35 -0
  89. package/dist/usage/NoOpUsageCollector.d.ts.map +1 -0
  90. package/dist/usage/NoOpUsageCollector.js +40 -0
  91. package/dist/usage/NoOpUsageCollector.js.map +1 -0
  92. package/dist/usage/UsageEventFactory.d.ts +80 -0
  93. package/dist/usage/UsageEventFactory.d.ts.map +1 -0
  94. package/dist/usage/UsageEventFactory.js +117 -0
  95. package/dist/usage/UsageEventFactory.js.map +1 -0
  96. package/dist/usage/index.d.ts +11 -0
  97. package/dist/usage/index.d.ts.map +1 -0
  98. package/dist/usage/index.js +11 -0
  99. package/dist/usage/index.js.map +1 -0
  100. package/package.json +7 -3
@@ -82,9 +82,9 @@ import { CLIAdapter, ShellAdapter, HTTPAdapter, FSAdapter } from '../adapters/bu
82
82
  import { InternalContextBuilder } from '../execution/InternalExecutionContext.js';
83
83
  import { IntentAnalyzer } from '../execution/IntentAnalyzer.js';
84
84
  import { ExecutionStrategyResolver, ExecutionStrategyGuard } from '../execution/ExecutionStrategyResolver.js';
85
- import { EngineEventType, } from '../types/core-types.js';
85
+ import { EngineEventType } from '../types/core-types.js';
86
86
  import { ExplanationGenerator, ExplanationLogger } from '../explanation/index.js';
87
- import { existsSync, mkdirSync } from 'node:fs';
87
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
88
88
  import { join } from 'node:path';
89
89
  import { homedir } from 'node:os';
90
90
  import { ExecutionStore } from '../storage/ExecutionStore.js';
@@ -92,6 +92,11 @@ import { WorkflowStore } from '../storage/WorkflowStore.js';
92
92
  import { ScheduleStore } from '../storage/ScheduleStore.js';
93
93
  import { WorkflowParseCache, AdapterMetadataCache } from '../cache/index.js';
94
94
  import { RuntimeArtifactStore } from '../runtime/index.js';
95
+ import { NoOpUsageCollector } from '../usage/NoOpUsageCollector.js';
96
+ import { FileSpoolUsageCollector, HttpUsageBatchTransport } from '../usage/FileSpoolUsageCollector.js';
97
+ import { DistributedWorkflowOrchestrator, FileDistributedJobQueue, InMemoryDistributedJobQueue } from '../distributed/index.js';
98
+ import { CheckpointStore } from '../storage/CheckpointStore.js';
99
+ import { createAdapterCallEvent, createStepExecuteEvent, createTriggerFireEvent, createWorkflowRunEvent, } from '../usage/UsageEventFactory.js';
95
100
  /**
96
101
  * OrbytEngine - Main public API
97
102
  *
@@ -234,6 +239,7 @@ export class OrbytEngine {
234
239
  workflowParseCache;
235
240
  adapterMetadataCache;
236
241
  runtimeArtifactStore;
242
+ usageCollector;
237
243
  constructor(config = {}) {
238
244
  // Validate and apply defaults
239
245
  validateConfig(config);
@@ -275,14 +281,53 @@ export class OrbytEngine {
275
281
  maxConcurrentExecutions: this.config.maxConcurrentWorkflows,
276
282
  defaultTimeout: this.config.defaultTimeout,
277
283
  enableScheduler: this.config.enableScheduler,
284
+ scheduler: this.config.scheduler,
278
285
  queue: this.config.queue,
279
286
  retryPolicy: this.config.retryPolicy,
280
287
  timeoutManager: this.config.timeoutManager,
281
288
  });
282
289
  // Wire components together
283
290
  this.setupComponents();
291
+ // Initialize usage collector.
292
+ // Priority:
293
+ // 1) user-provided collector
294
+ // 2) built-in durable file spool collector (default and production-safe)
295
+ // 3) no-op collector (testing-only escape hatch)
296
+ if (this.config.usageCollector) {
297
+ this.usageCollector = this.config.usageCollector;
298
+ }
299
+ else {
300
+ const usageSpool = this.config.usageSpool;
301
+ const spoolEnabled = usageSpool?.enabled ?? true;
302
+ const spoolBaseDir = usageSpool?.baseDir ?? join(homedir(), '.orbyt', 'usage');
303
+ const allowNoOpForTesting = process.env.NODE_ENV === 'test' ||
304
+ process.env.ORBYT_ALLOW_NOOP_USAGE_COLLECTOR === '1';
305
+ if (spoolEnabled || !allowNoOpForTesting) {
306
+ if (!spoolEnabled && !allowNoOpForTesting) {
307
+ this.log('warn', 'usageSpool.enabled=false ignored outside test mode; using durable spool collector');
308
+ }
309
+ const transport = usageSpool?.billingEndpoint
310
+ ? new HttpUsageBatchTransport({
311
+ endpoint: usageSpool.billingEndpoint,
312
+ apiKey: usageSpool.billingApiKey,
313
+ timeoutMs: usageSpool.requestTimeoutMs,
314
+ })
315
+ : undefined;
316
+ this.usageCollector = new FileSpoolUsageCollector({
317
+ baseDir: spoolBaseDir,
318
+ batchSize: usageSpool?.batchSize,
319
+ flushIntervalMs: usageSpool?.flushIntervalMs,
320
+ maxRetryAttempts: usageSpool?.maxRetryAttempts,
321
+ transport,
322
+ });
323
+ }
324
+ else {
325
+ this.usageCollector = new NoOpUsageCollector();
326
+ }
327
+ }
284
328
  // Ensure infrastructure directories exist before any persistence/caching.
285
329
  this.bootstrapRuntimeDirectories();
330
+ this.ensureFirstRunConfigFile();
286
331
  // Initialise persistent stores (non-fatal — must never block engine startup)
287
332
  const storeRoot = this.config.stateDir ?? join(homedir(), '.orbyt');
288
333
  this.executionStore = new ExecutionStore(join(storeRoot, 'executions'));
@@ -360,6 +405,7 @@ export class OrbytEngine {
360
405
  orbytHome,
361
406
  this.config.stateDir,
362
407
  join(this.config.stateDir, 'executions'),
408
+ join(this.config.stateDir, 'checkpoints'),
363
409
  join(this.config.stateDir, 'workflows'),
364
410
  join(this.config.stateDir, 'schedules'),
365
411
  this.config.logDir,
@@ -373,6 +419,11 @@ export class OrbytEngine {
373
419
  join(orbytHome, 'plugins'),
374
420
  join(orbytHome, 'metrics'),
375
421
  join(orbytHome, 'config'),
422
+ join(orbytHome, 'usage'),
423
+ join(orbytHome, 'usage', 'events'),
424
+ join(orbytHome, 'usage', 'pending'),
425
+ join(orbytHome, 'usage', 'sent'),
426
+ join(orbytHome, 'usage', 'failed'),
376
427
  join(orbytHome, 'tmp'),
377
428
  join(orbytHome, 'cloud-sync'),
378
429
  ];
@@ -385,6 +436,54 @@ export class OrbytEngine {
385
436
  }
386
437
  }
387
438
  }
439
+ /**
440
+ * Ensure a first-run config file exists at ~/.orbyt/config/config.json.
441
+ *
442
+ * This file is created once and then preserved as the local runtime config
443
+ * snapshot for visibility and tooling introspection.
444
+ */
445
+ ensureFirstRunConfigFile() {
446
+ const configDir = join(homedir(), '.orbyt', 'config');
447
+ const configPath = join(configDir, 'config.json');
448
+ if (existsSync(configPath)) {
449
+ return;
450
+ }
451
+ try {
452
+ mkdirSync(configDir, { recursive: true });
453
+ const payload = {
454
+ version: 1,
455
+ createdAt: new Date().toISOString(),
456
+ source: 'orbyt-engine',
457
+ engine: {
458
+ version: this.version,
459
+ mode: this.config.mode,
460
+ logLevel: this.config.logLevel,
461
+ maxConcurrentWorkflows: this.config.maxConcurrentWorkflows,
462
+ maxConcurrentSteps: this.config.maxConcurrentSteps,
463
+ defaultTimeout: this.config.defaultTimeout,
464
+ enableScheduler: this.config.enableScheduler,
465
+ enableMetrics: this.config.enableMetrics,
466
+ enableEvents: this.config.enableEvents,
467
+ sandboxMode: this.config.sandboxMode,
468
+ },
469
+ paths: {
470
+ stateDir: this.config.stateDir,
471
+ logDir: this.config.logDir,
472
+ cacheDir: this.config.cacheDir,
473
+ runtimeDir: this.config.runtimeDir,
474
+ workingDirectory: this.config.workingDirectory,
475
+ },
476
+ usageSpool: this.config.usageSpool,
477
+ };
478
+ writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
479
+ this.log('debug', 'Created first-run engine config file', { configPath });
480
+ }
481
+ catch (error) {
482
+ this.log('warn', 'Failed to create first-run engine config file', {
483
+ error: error instanceof Error ? error.message : String(error),
484
+ });
485
+ }
486
+ }
388
487
  /**
389
488
  * Register built-in adapters
390
489
  * These are the core adapters shipped with Orbyt
@@ -424,6 +523,14 @@ export class OrbytEngine {
424
523
  // Emit engine.stopped event
425
524
  await this.eventBus.emit(createEvent(EngineEventType.ENGINE_STOPPED, { timestamp: Date.now() }, {}));
426
525
  this.log('info', 'Orbyt Engine stopped');
526
+ // Best-effort collector draining/cleanup on shutdown.
527
+ try {
528
+ await this.usageCollector.flush?.();
529
+ await this.usageCollector.close?.();
530
+ }
531
+ catch {
532
+ // Non-fatal on shutdown
533
+ }
427
534
  }
428
535
  /**
429
536
  * Run a workflow
@@ -662,6 +769,8 @@ export class OrbytEngine {
662
769
  continueOnError: options.continueOnError,
663
770
  dryRun: options.dryRun,
664
771
  triggeredBy: options.triggeredBy,
772
+ resumeFromRunId: options.resumeFromRunId,
773
+ resumePolicy: options.resumePolicy,
665
774
  _ownershipContext: options._ownershipContext,
666
775
  _permissionPolicy: options._permissionPolicy,
667
776
  };
@@ -749,7 +858,34 @@ export class OrbytEngine {
749
858
  },
750
859
  continueOnError: sanitizedOptions.continueOnError,
751
860
  triggeredBy: sanitizedOptions.triggeredBy || 'manual',
861
+ resumeFromRunId: sanitizedOptions.resumeFromRunId,
862
+ resumePolicy: sanitizedOptions.resumePolicy,
752
863
  };
864
+ if (execOptions.triggeredBy && execOptions.triggeredBy !== 'manual') {
865
+ internalContext._usage.triggerFireCount += 1;
866
+ this.recordUsageEvent(createTriggerFireEvent({
867
+ executionId: internalContext._identity.executionId,
868
+ workflowId: parsedWorkflow.name || parsedWorkflow.metadata?.name,
869
+ userId: internalContext._ownership.userId,
870
+ workspaceId: internalContext._ownership.workspaceId,
871
+ pricingTier: internalContext._billing.pricingTierResolved,
872
+ billable: internalContext._billing.isBillable,
873
+ metadata: {
874
+ success: true,
875
+ triggeredBy: execOptions.triggeredBy,
876
+ },
877
+ }));
878
+ }
879
+ internalContext._usage.automationCount += 1;
880
+ this.recordUsageEvent(createWorkflowRunEvent({
881
+ executionId: internalContext._identity.executionId,
882
+ workflowId: parsedWorkflow.name || parsedWorkflow.metadata?.name,
883
+ userId: internalContext._ownership.userId,
884
+ workspaceId: internalContext._ownership.workspaceId,
885
+ executionMode: isolatedRuntime ? 'parallel' : 'single',
886
+ pricingTier: internalContext._billing.pricingTierResolved,
887
+ billable: internalContext._billing.isBillable,
888
+ }));
753
889
  this.log('info', `Running workflow: ${parsedWorkflow.name || 'unnamed'}`);
754
890
  this.workflowStore.save(parsedWorkflow);
755
891
  // Parallel/mixed mode can execute multiple workflows concurrently via runMany().
@@ -761,10 +897,11 @@ export class OrbytEngine {
761
897
  stepExecutor: this.stepExecutor,
762
898
  workflowExecutor: this.workflowExecutor,
763
899
  };
764
- const result = await this.measureWorkflowExecution(parsedWorkflow.name || 'unnamed', () => runtime.workflowExecutor.execute(parsedWorkflow, execOptions));
765
- internalContext._usage.stepCount = result.metadata.totalSteps;
900
+ const useDistributedRuntime = this.config.mode === 'distributed';
901
+ const result = await this.measureWorkflowExecution(parsedWorkflow.name || 'unnamed', () => useDistributedRuntime
902
+ ? this.executeDistributedWorkflow(parsedWorkflow, execOptions, runtime.stepExecutor)
903
+ : runtime.workflowExecutor.execute(parsedWorkflow, execOptions));
766
904
  internalContext._usage.durationSeconds = result.duration / 1000;
767
- internalContext._usage.weightedStepCount = result.metadata.totalSteps;
768
905
  this.log('info', `Workflow completed: ${result.status}`, {
769
906
  durationMs: result.duration,
770
907
  steps: result.metadata.totalSteps,
@@ -772,6 +909,54 @@ export class OrbytEngine {
772
909
  automationCount: internalContext._usage.automationCount,
773
910
  stepCount: internalContext._usage.stepCount,
774
911
  });
912
+ const workflowId = parsedWorkflow.name || parsedWorkflow.metadata?.name;
913
+ for (const [stepId, stepResult] of result.stepResults.entries()) {
914
+ const stepDefinition = parsedWorkflow.steps.find((step) => step.id === stepId);
915
+ if (!stepDefinition)
916
+ continue;
917
+ const success = stepResult.status === 'success';
918
+ const errorMessage = stepResult.error?.message;
919
+ const executed = stepResult.status !== 'skipped';
920
+ if (executed) {
921
+ internalContext._usage.stepCount += 1;
922
+ internalContext._usage.weightedStepCount += 1;
923
+ }
924
+ if (executed) {
925
+ this.recordUsageEvent(createStepExecuteEvent({
926
+ executionId: internalContext._identity.executionId,
927
+ stepId,
928
+ workflowId,
929
+ userId: internalContext._ownership.userId,
930
+ workspaceId: internalContext._ownership.workspaceId,
931
+ adapterType: stepDefinition.adapter,
932
+ adapterName: stepDefinition.action,
933
+ durationMs: stepResult.duration,
934
+ success,
935
+ retries: Math.max(0, stepResult.attempts - 1),
936
+ error: errorMessage,
937
+ pricingTier: internalContext._billing.pricingTierResolved,
938
+ billable: internalContext._billing.isBillable,
939
+ }));
940
+ }
941
+ if (executed) {
942
+ internalContext._usage.adapterCallCount += 1;
943
+ this.recordUsageEvent(createAdapterCallEvent({
944
+ executionId: internalContext._identity.executionId,
945
+ stepId,
946
+ adapterType: stepDefinition.adapter,
947
+ adapterName: stepDefinition.action,
948
+ workflowId,
949
+ userId: internalContext._ownership.userId,
950
+ workspaceId: internalContext._ownership.workspaceId,
951
+ durationMs: stepResult.duration,
952
+ success,
953
+ retries: Math.max(0, stepResult.attempts - 1),
954
+ error: errorMessage,
955
+ pricingTier: internalContext._billing.pricingTierResolved,
956
+ billable: internalContext._billing.isBillable,
957
+ }));
958
+ }
959
+ }
775
960
  await this.onWorkflowBillingComplete(internalContext, result);
776
961
  return result;
777
962
  });
@@ -779,6 +964,42 @@ export class OrbytEngine {
779
964
  LoggerManager.clearWorkflowContext();
780
965
  return result;
781
966
  }
967
+ async executeDistributedWorkflow(workflow, execOptions, stepExecutor) {
968
+ const distributed = this.config.distributed;
969
+ const queueBackend = distributed?.queueBackend ?? 'memory';
970
+ const queueStateDir = distributed?.fileQueueStateDir ?? join(homedir(), '.orbyt', 'distributed-queue');
971
+ const workerCount = distributed?.workerCount
972
+ ?? this.config.scheduler?.job?.workerCount
973
+ ?? Math.max(1, Math.min(8, this.config.maxConcurrentSteps || 4));
974
+ const queue = distributed?.jobQueue
975
+ ?? (queueBackend === 'file'
976
+ ? new FileDistributedJobQueue({
977
+ stateDir: queueStateDir,
978
+ leaseMs: distributed?.leaseMs,
979
+ })
980
+ : new InMemoryDistributedJobQueue({
981
+ leaseMs: distributed?.leaseMs,
982
+ }));
983
+ const orchestrator = new DistributedWorkflowOrchestrator({
984
+ queue,
985
+ stepExecutor,
986
+ workerCount,
987
+ pollIntervalMs: distributed?.pollIntervalMs,
988
+ eventBus: this.eventBus,
989
+ hookManager: this.hookManager,
990
+ executionStore: this.executionStore,
991
+ checkpointStore: new CheckpointStore(join(this.config.stateDir, 'checkpoints')),
992
+ leaseExtensionMs: distributed?.leaseExtensionMs,
993
+ });
994
+ this.log('info', '[Distributed] Executing workflow via scheduler->queue->workers runtime', {
995
+ workerCount,
996
+ queueBackend: distributed?.jobQueue
997
+ ? 'custom'
998
+ : queueBackend,
999
+ workflowId: workflow.name || workflow.metadata?.name,
1000
+ });
1001
+ return orchestrator.execute(workflow, execOptions);
1002
+ }
782
1003
  /**
783
1004
  * Create isolated per-workflow execution runtime.
784
1005
  *
@@ -861,6 +1082,31 @@ export class OrbytEngine {
861
1082
  // });
862
1083
  // }
863
1084
  }
1085
+ /**
1086
+ * Safe usage event recording
1087
+ *
1088
+ * Records a usage event through the configured collector.
1089
+ * Failures are logged but never propagate (non-fatal).
1090
+ * Called asynchronously and does not block execution.
1091
+ *
1092
+ * @param event - Usage event to record
1093
+ */
1094
+ recordUsageEvent(event) {
1095
+ // Fire-and-forget: record usage asynchronously without blocking
1096
+ process.nextTick(async () => {
1097
+ try {
1098
+ await this.usageCollector.record(event);
1099
+ }
1100
+ catch (error) {
1101
+ // Log but don't propagate - usage tracking must never fail execution
1102
+ this.log('debug', 'Usage collection failed (non-fatal)', {
1103
+ error: error instanceof Error ? error.message : String(error),
1104
+ eventType: event.type,
1105
+ executionId: event.executionId,
1106
+ });
1107
+ }
1108
+ });
1109
+ }
864
1110
  /**
865
1111
  * Validate a workflow without executing it
866
1112
  *
@@ -1065,6 +1311,226 @@ export class OrbytEngine {
1065
1311
  getVersion() {
1066
1312
  return this.version;
1067
1313
  }
1314
+ /**
1315
+ * Query usage events from the local spool archive and return aggregated metrics.
1316
+ */
1317
+ async getUsage(options = {}) {
1318
+ const now = Date.now();
1319
+ const from = options.from ?? now - 30 * 24 * 60 * 60 * 1000;
1320
+ const to = options.to ?? now;
1321
+ const groupBy = options.groupBy ?? 'none';
1322
+ if (from > to) {
1323
+ throw new Error(`Invalid usage query range: from (${from}) must be <= to (${to})`);
1324
+ }
1325
+ const usageBaseDir = this.config.usageSpool?.baseDir ?? join(homedir(), '.orbyt', 'usage');
1326
+ const eventsDir = join(usageBaseDir, 'events');
1327
+ const filteredEvents = [];
1328
+ if (existsSync(eventsDir)) {
1329
+ const files = readdirSync(eventsDir)
1330
+ .filter((name) => name.endsWith('.jsonl'))
1331
+ .sort();
1332
+ for (const file of files) {
1333
+ const filePath = join(eventsDir, file);
1334
+ let content = '';
1335
+ try {
1336
+ content = readFileSync(filePath, 'utf8');
1337
+ }
1338
+ catch {
1339
+ continue;
1340
+ }
1341
+ if (!content.trim()) {
1342
+ continue;
1343
+ }
1344
+ const lines = content.split('\n');
1345
+ for (const line of lines) {
1346
+ const trimmed = line.trim();
1347
+ if (!trimmed) {
1348
+ continue;
1349
+ }
1350
+ let event;
1351
+ try {
1352
+ event = JSON.parse(trimmed);
1353
+ }
1354
+ catch {
1355
+ continue;
1356
+ }
1357
+ if (!this.matchesUsageQuery(event, options, from, to)) {
1358
+ continue;
1359
+ }
1360
+ filteredEvents.push(event);
1361
+ }
1362
+ }
1363
+ }
1364
+ const byType = {};
1365
+ const byProduct = {};
1366
+ const byAdapter = {};
1367
+ const byWorkflow = {};
1368
+ const byWorkspace = {};
1369
+ const byUser = {};
1370
+ const byTrigger = {};
1371
+ const groupedMap = new Map();
1372
+ let billableEvents = 0;
1373
+ let successEvents = 0;
1374
+ let failureEvents = 0;
1375
+ let totalDurationMs = 0;
1376
+ for (const event of filteredEvents) {
1377
+ byType[event.type] = (byType[event.type] ?? 0) + 1;
1378
+ byProduct[event.product] = (byProduct[event.product] ?? 0) + 1;
1379
+ const adapterKey = event.adapterName || event.adapterType || 'unknown';
1380
+ byAdapter[adapterKey] = (byAdapter[adapterKey] ?? 0) + 1;
1381
+ const workflowKey = event.workflowId || 'unknown';
1382
+ byWorkflow[workflowKey] = (byWorkflow[workflowKey] ?? 0) + 1;
1383
+ const workspaceKey = event.workspaceId || 'unknown';
1384
+ byWorkspace[workspaceKey] = (byWorkspace[workspaceKey] ?? 0) + 1;
1385
+ const userKey = event.userId || 'unknown';
1386
+ byUser[userKey] = (byUser[userKey] ?? 0) + 1;
1387
+ const triggerKey = this.resolveTriggerKey(event);
1388
+ byTrigger[triggerKey] = (byTrigger[triggerKey] ?? 0) + 1;
1389
+ if (event.billable === true) {
1390
+ billableEvents += 1;
1391
+ }
1392
+ if (event.metadata?.success === true) {
1393
+ successEvents += 1;
1394
+ }
1395
+ else if (event.metadata?.success === false) {
1396
+ failureEvents += 1;
1397
+ }
1398
+ const durationMs = typeof event.metadata?.durationMs === 'number'
1399
+ ? Math.max(0, event.metadata.durationMs)
1400
+ : 0;
1401
+ totalDurationMs += durationMs;
1402
+ if (groupBy !== 'none') {
1403
+ const key = this.resolveUsageGroupKey(event, groupBy);
1404
+ const existing = groupedMap.get(key) ?? {
1405
+ key,
1406
+ eventCount: 0,
1407
+ billableCount: 0,
1408
+ successCount: 0,
1409
+ failureCount: 0,
1410
+ totalDurationMs: 0,
1411
+ };
1412
+ existing.eventCount += 1;
1413
+ if (event.billable === true) {
1414
+ existing.billableCount += 1;
1415
+ }
1416
+ if (event.metadata?.success === true) {
1417
+ existing.successCount += 1;
1418
+ }
1419
+ else if (event.metadata?.success === false) {
1420
+ existing.failureCount += 1;
1421
+ }
1422
+ existing.totalDurationMs += durationMs;
1423
+ groupedMap.set(key, existing);
1424
+ }
1425
+ }
1426
+ const normalizedLimit = typeof options.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0
1427
+ ? Math.floor(options.limit)
1428
+ : undefined;
1429
+ const outputEvents = options.includeEvents
1430
+ ? (normalizedLimit ? filteredEvents.slice(-normalizedLimit) : filteredEvents)
1431
+ : undefined;
1432
+ return {
1433
+ query: {
1434
+ from,
1435
+ to,
1436
+ groupBy,
1437
+ limit: normalizedLimit,
1438
+ },
1439
+ totalEvents: filteredEvents.length,
1440
+ billableEvents,
1441
+ successEvents,
1442
+ failureEvents,
1443
+ totalDurationMs,
1444
+ byType,
1445
+ byProduct,
1446
+ byAdapter,
1447
+ byWorkflow,
1448
+ byWorkspace,
1449
+ byUser,
1450
+ byTrigger,
1451
+ grouped: Array.from(groupedMap.values()).sort((a, b) => a.key.localeCompare(b.key)),
1452
+ events: outputEvents?.map((event) => ({
1453
+ id: event.id,
1454
+ type: event.type,
1455
+ timestamp: event.timestamp,
1456
+ product: event.product,
1457
+ executionId: event.executionId,
1458
+ workflowId: event.workflowId,
1459
+ stepId: event.stepId,
1460
+ adapterName: event.adapterName,
1461
+ adapterType: event.adapterType,
1462
+ billable: event.billable,
1463
+ metadata: event.metadata,
1464
+ })),
1465
+ };
1466
+ }
1467
+ matchesUsageQuery(event, options, from, to) {
1468
+ if (!event || typeof event !== 'object')
1469
+ return false;
1470
+ if (typeof event.timestamp !== 'number')
1471
+ return false;
1472
+ if (event.timestamp < from || event.timestamp > to)
1473
+ return false;
1474
+ if (options.userId && event.userId !== options.userId)
1475
+ return false;
1476
+ if (options.workspaceId && event.workspaceId !== options.workspaceId)
1477
+ return false;
1478
+ if (options.product && event.product !== options.product)
1479
+ return false;
1480
+ if (options.eventType && event.type !== options.eventType)
1481
+ return false;
1482
+ if (options.adapterName && event.adapterName !== options.adapterName)
1483
+ return false;
1484
+ if (options.adapterType && event.adapterType !== options.adapterType)
1485
+ return false;
1486
+ return true;
1487
+ }
1488
+ resolveUsageGroupKey(event, groupBy) {
1489
+ if (groupBy === 'workflow') {
1490
+ return event.workflowId || 'unknown';
1491
+ }
1492
+ if (groupBy === 'adapter') {
1493
+ return event.adapterName || event.adapterType || 'unknown';
1494
+ }
1495
+ if (groupBy === 'trigger') {
1496
+ return this.resolveTriggerKey(event);
1497
+ }
1498
+ if (groupBy === 'product') {
1499
+ return event.product || 'unknown';
1500
+ }
1501
+ if (groupBy === 'workspace') {
1502
+ return event.workspaceId || 'unknown';
1503
+ }
1504
+ if (groupBy === 'user') {
1505
+ return event.userId || 'unknown';
1506
+ }
1507
+ if (groupBy === 'type') {
1508
+ return event.type || 'unknown';
1509
+ }
1510
+ const date = new Date(event.timestamp);
1511
+ if (groupBy === 'hourly') {
1512
+ return `${date.toISOString().slice(0, 13)}:00:00Z`;
1513
+ }
1514
+ if (groupBy === 'daily') {
1515
+ return date.toISOString().slice(0, 10);
1516
+ }
1517
+ if (groupBy === 'weekly') {
1518
+ const day = date.getUTCDay();
1519
+ const shift = day === 0 ? -6 : 1 - day;
1520
+ const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + shift));
1521
+ return start.toISOString().slice(0, 10);
1522
+ }
1523
+ return 'all';
1524
+ }
1525
+ resolveTriggerKey(event) {
1526
+ if (event.type === 'usage.trigger.fire') {
1527
+ const triggerType = typeof event.metadata?.triggerType === 'string'
1528
+ ? event.metadata.triggerType
1529
+ : undefined;
1530
+ return triggerType || 'trigger.fire';
1531
+ }
1532
+ return 'non-trigger';
1533
+ }
1068
1534
  /**
1069
1535
  * Internal logging method
1070
1536
  *