@auto-engineer/pipeline 1.95.0 → 1.97.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.
package/package.json CHANGED
@@ -13,8 +13,8 @@
13
13
  "get-port": "^7.1.0",
14
14
  "jose": "^5.9.6",
15
15
  "nanoid": "^5.0.0",
16
- "@auto-engineer/file-store": "1.95.0",
17
- "@auto-engineer/message-bus": "1.95.0"
16
+ "@auto-engineer/file-store": "1.97.0",
17
+ "@auto-engineer/message-bus": "1.97.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/cors": "^2.8.17",
@@ -23,7 +23,7 @@
23
23
  "publishConfig": {
24
24
  "access": "public"
25
25
  },
26
- "version": "1.95.0",
26
+ "version": "1.97.0",
27
27
  "scripts": {
28
28
  "build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
29
29
  "test": "vitest run --reporter=dot",
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export type { ConcurrencyConfig } from './server/command-gate';
47
47
  export type { CommandHandlerWithMetadata, PipelineServerConfig } from './server/pipeline-server';
48
48
  export { PipelineServer } from './server/pipeline-server';
49
49
  export { SSEManager } from './server/sse-manager';
50
+ export type { RunStats } from './store/pipeline-read-model';
50
51
  export type { SnapshotDiff, SnapshotResult } from './testing/snapshot-compare';
51
52
  export {
52
53
  compareEventSequence,
@@ -59,6 +59,13 @@ interface StatsResponse {
59
59
  totalMessages: number;
60
60
  }
61
61
 
62
+ interface RunStatsResponse {
63
+ pipelineStatus: string;
64
+ correlationId: string;
65
+ items: { total: number; running: number; success: number; error: number; retried: number };
66
+ nodes: { total: number; running: number; success: number; error: number };
67
+ }
68
+
62
69
  async function fetchAs<T>(url: string, options?: RequestInit): Promise<T> {
63
70
  const res = await fetch(url, options);
64
71
  return res.json() as Promise<T>;
@@ -948,6 +955,69 @@ describe('PipelineServer', () => {
948
955
  });
949
956
  });
950
957
 
958
+ describe('GET /run-stats', () => {
959
+ it('should return idle status when no activity exists', async () => {
960
+ const server = new PipelineServer({ port: 0 });
961
+ await server.start();
962
+
963
+ const data = await fetchAs<RunStatsResponse>(`http://localhost:${server.port}/run-stats`);
964
+
965
+ expect(data.pipelineStatus).toBe('idle');
966
+ expect(data.correlationId).toBeDefined();
967
+ expect(data.items).toEqual({ total: 0, running: 0, success: 0, error: 0, retried: 0 });
968
+ expect(data.nodes).toEqual({ total: 0, running: 0, success: 0, error: 0 });
969
+ await server.stop();
970
+ });
971
+
972
+ it('should return active status when pipeline is processing', async () => {
973
+ const handler = {
974
+ name: 'SlowCmd',
975
+ handle: async () => {
976
+ await new Promise((r) => setTimeout(r, 500));
977
+ return { type: 'SlowDone', data: {} };
978
+ },
979
+ };
980
+ const server = new PipelineServer({ port: 0 });
981
+ server.registerCommandHandlers([handler]);
982
+ await server.start();
983
+
984
+ void fetch(`http://localhost:${server.port}/command`, {
985
+ method: 'POST',
986
+ headers: { 'Content-Type': 'application/json' },
987
+ body: JSON.stringify({ type: 'SlowCmd', data: {} }),
988
+ });
989
+ await new Promise((r) => setTimeout(r, 50));
990
+
991
+ const data = await fetchAs<RunStatsResponse>(`http://localhost:${server.port}/run-stats`);
992
+
993
+ expect(data.pipelineStatus).toBe('active');
994
+ await server.stop();
995
+ });
996
+
997
+ it('should return completed status after pipeline finishes', async () => {
998
+ const handler = {
999
+ name: 'QuickCmd',
1000
+ handle: async () => ({ type: 'QuickDone', data: {} }),
1001
+ };
1002
+ const server = new PipelineServer({ port: 0 });
1003
+ server.registerCommandHandlers([handler]);
1004
+ await server.start();
1005
+
1006
+ await fetch(`http://localhost:${server.port}/command`, {
1007
+ method: 'POST',
1008
+ headers: { 'Content-Type': 'application/json' },
1009
+ body: JSON.stringify({ type: 'QuickCmd', data: {} }),
1010
+ });
1011
+ await new Promise((r) => setTimeout(r, 200));
1012
+
1013
+ const data = await fetchAs<RunStatsResponse>(`http://localhost:${server.port}/run-stats`);
1014
+
1015
+ expect(data.pipelineStatus).toBe('completed');
1016
+ expect(data.items.total).toBeGreaterThanOrEqual(1);
1017
+ await server.stop();
1018
+ });
1019
+ });
1020
+
951
1021
  describe('event routing', () => {
952
1022
  it('should route events through pipeline', async () => {
953
1023
  const handler = {
@@ -395,6 +395,40 @@ export class PipelineServer {
395
395
  })();
396
396
  });
397
397
 
398
+ this.app.get('/run-stats', (req, res) => {
399
+ void (async () => {
400
+ const correlationId = (req.query.correlationId as string) || this.currentSessionId;
401
+ if (!correlationId) {
402
+ res.json({
403
+ pipelineStatus: 'idle',
404
+ correlationId: '',
405
+ items: { total: 0, running: 0, success: 0, error: 0, retried: 0 },
406
+ nodes: { total: 0, running: 0, success: 0, error: 0 },
407
+ });
408
+ return;
409
+ }
410
+
411
+ const runStats = await this.eventStoreContext.readModel.getRunStats(correlationId);
412
+ const hasActivity = runStats.items.total > 0 || runStats.nodes.total > 0;
413
+ const isQuiescent = this.quiescenceTracker.isQuiescent();
414
+
415
+ let pipelineStatus: 'idle' | 'active' | 'completed';
416
+ if (!isQuiescent) {
417
+ pipelineStatus = 'active';
418
+ } else if (hasActivity) {
419
+ pipelineStatus = 'completed';
420
+ } else {
421
+ pipelineStatus = 'idle';
422
+ }
423
+
424
+ res.json({
425
+ pipelineStatus,
426
+ correlationId,
427
+ ...runStats,
428
+ });
429
+ })();
430
+ });
431
+
398
432
  this.app.get('/events', (req, res) => {
399
433
  const clientId = `sse-${nanoid()}`;
400
434
  const correlationIdFilter = req.query.correlationId as string | undefined;
@@ -571,6 +571,367 @@ describe('PipelineReadModel', () => {
571
571
  });
572
572
  });
573
573
 
574
+ describe('getAllItemStatuses', () => {
575
+ it('should return empty array when no items exist', async () => {
576
+ const result = await readModel.getAllItemStatuses('c1');
577
+
578
+ expect(result).toEqual([]);
579
+ });
580
+
581
+ it('should return all items for correlationId', async () => {
582
+ const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
583
+ await collection.insertOne({
584
+ _id: 'c1-Cmd-a',
585
+ correlationId: 'c1',
586
+ commandType: 'Cmd',
587
+ itemKey: 'a',
588
+ currentRequestId: 'r1',
589
+ status: 'running',
590
+ attemptCount: 1,
591
+ });
592
+ await collection.insertOne({
593
+ _id: 'c1-Cmd-b',
594
+ correlationId: 'c1',
595
+ commandType: 'Cmd',
596
+ itemKey: 'b',
597
+ currentRequestId: 'r2',
598
+ status: 'success',
599
+ attemptCount: 1,
600
+ });
601
+
602
+ const result = await readModel.getAllItemStatuses('c1');
603
+
604
+ expect(result).toEqual([
605
+ {
606
+ correlationId: 'c1',
607
+ commandType: 'Cmd',
608
+ itemKey: 'a',
609
+ currentRequestId: 'r1',
610
+ status: 'running',
611
+ attemptCount: 1,
612
+ },
613
+ {
614
+ correlationId: 'c1',
615
+ commandType: 'Cmd',
616
+ itemKey: 'b',
617
+ currentRequestId: 'r2',
618
+ status: 'success',
619
+ attemptCount: 1,
620
+ },
621
+ ]);
622
+ });
623
+
624
+ it('should filter by correlationId', async () => {
625
+ const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
626
+ await collection.insertOne({
627
+ _id: 'c1-Cmd-a',
628
+ correlationId: 'c1',
629
+ commandType: 'Cmd',
630
+ itemKey: 'a',
631
+ currentRequestId: 'r1',
632
+ status: 'running',
633
+ attemptCount: 1,
634
+ });
635
+ await collection.insertOne({
636
+ _id: 'c2-Cmd-a',
637
+ correlationId: 'c2',
638
+ commandType: 'Cmd',
639
+ itemKey: 'a',
640
+ currentRequestId: 'r2',
641
+ status: 'success',
642
+ attemptCount: 1,
643
+ });
644
+
645
+ const result = await readModel.getAllItemStatuses('c1');
646
+
647
+ expect(result).toEqual([
648
+ {
649
+ correlationId: 'c1',
650
+ commandType: 'Cmd',
651
+ itemKey: 'a',
652
+ currentRequestId: 'r1',
653
+ status: 'running',
654
+ attemptCount: 1,
655
+ },
656
+ ]);
657
+ });
658
+ });
659
+
660
+ describe('getAllNodeStatuses', () => {
661
+ it('should return empty array when no nodes exist', async () => {
662
+ const result = await readModel.getAllNodeStatuses('c1');
663
+
664
+ expect(result).toEqual([]);
665
+ });
666
+
667
+ it('should return all nodes for correlationId', async () => {
668
+ const collection = database.collection<WithId<NodeStatusDocument>>('NodeStatus');
669
+ await collection.insertOne({
670
+ _id: 'c1-CmdA',
671
+ correlationId: 'c1',
672
+ commandName: 'CmdA',
673
+ status: 'running',
674
+ pendingCount: 1,
675
+ endedCount: 0,
676
+ });
677
+ await collection.insertOne({
678
+ _id: 'c1-CmdB',
679
+ correlationId: 'c1',
680
+ commandName: 'CmdB',
681
+ status: 'success',
682
+ pendingCount: 0,
683
+ endedCount: 2,
684
+ });
685
+
686
+ const result = await readModel.getAllNodeStatuses('c1');
687
+
688
+ expect(result).toEqual([
689
+ {
690
+ correlationId: 'c1',
691
+ commandName: 'CmdA',
692
+ status: 'running',
693
+ pendingCount: 1,
694
+ endedCount: 0,
695
+ },
696
+ {
697
+ correlationId: 'c1',
698
+ commandName: 'CmdB',
699
+ status: 'success',
700
+ pendingCount: 0,
701
+ endedCount: 2,
702
+ },
703
+ ]);
704
+ });
705
+
706
+ it('should filter by correlationId', async () => {
707
+ const collection = database.collection<WithId<NodeStatusDocument>>('NodeStatus');
708
+ await collection.insertOne({
709
+ _id: 'c1-Cmd',
710
+ correlationId: 'c1',
711
+ commandName: 'Cmd',
712
+ status: 'running',
713
+ pendingCount: 1,
714
+ endedCount: 0,
715
+ });
716
+ await collection.insertOne({
717
+ _id: 'c2-Cmd',
718
+ correlationId: 'c2',
719
+ commandName: 'Cmd',
720
+ status: 'success',
721
+ pendingCount: 0,
722
+ endedCount: 1,
723
+ });
724
+
725
+ const result = await readModel.getAllNodeStatuses('c1');
726
+
727
+ expect(result).toEqual([
728
+ {
729
+ correlationId: 'c1',
730
+ commandName: 'Cmd',
731
+ status: 'running',
732
+ pendingCount: 1,
733
+ endedCount: 0,
734
+ },
735
+ ]);
736
+ });
737
+ });
738
+
739
+ describe('getRunStats', () => {
740
+ it('should return zero counts when no data exists', async () => {
741
+ const result = await readModel.getRunStats('c1');
742
+
743
+ expect(result).toEqual({
744
+ items: { total: 0, running: 0, success: 0, error: 0, retried: 0 },
745
+ nodes: { total: 0, running: 0, success: 0, error: 0 },
746
+ });
747
+ });
748
+
749
+ it('should count items by status', async () => {
750
+ const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
751
+ await collection.insertOne({
752
+ _id: 'c1-Cmd-a',
753
+ correlationId: 'c1',
754
+ commandType: 'Cmd',
755
+ itemKey: 'a',
756
+ currentRequestId: 'r1',
757
+ status: 'running',
758
+ attemptCount: 1,
759
+ });
760
+ await collection.insertOne({
761
+ _id: 'c1-Cmd-b',
762
+ correlationId: 'c1',
763
+ commandType: 'Cmd',
764
+ itemKey: 'b',
765
+ currentRequestId: 'r2',
766
+ status: 'success',
767
+ attemptCount: 1,
768
+ });
769
+ await collection.insertOne({
770
+ _id: 'c1-Cmd-c',
771
+ correlationId: 'c1',
772
+ commandType: 'Cmd',
773
+ itemKey: 'c',
774
+ currentRequestId: 'r3',
775
+ status: 'error',
776
+ attemptCount: 1,
777
+ });
778
+
779
+ const result = await readModel.getRunStats('c1');
780
+
781
+ expect(result.items).toEqual({
782
+ total: 3,
783
+ running: 1,
784
+ success: 1,
785
+ error: 1,
786
+ retried: 0,
787
+ });
788
+ });
789
+
790
+ it('should count retried items with attemptCount > 1', async () => {
791
+ const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
792
+ await collection.insertOne({
793
+ _id: 'c1-Cmd-a',
794
+ correlationId: 'c1',
795
+ commandType: 'Cmd',
796
+ itemKey: 'a',
797
+ currentRequestId: 'r1',
798
+ status: 'success',
799
+ attemptCount: 2,
800
+ });
801
+ await collection.insertOne({
802
+ _id: 'c1-Cmd-b',
803
+ correlationId: 'c1',
804
+ commandType: 'Cmd',
805
+ itemKey: 'b',
806
+ currentRequestId: 'r2',
807
+ status: 'success',
808
+ attemptCount: 1,
809
+ });
810
+
811
+ const result = await readModel.getRunStats('c1');
812
+
813
+ expect(result.items).toEqual({
814
+ total: 2,
815
+ running: 0,
816
+ success: 2,
817
+ error: 0,
818
+ retried: 1,
819
+ });
820
+ });
821
+
822
+ it('should count nodes by status', async () => {
823
+ const collection = database.collection<WithId<NodeStatusDocument>>('NodeStatus');
824
+ await collection.insertOne({
825
+ _id: 'c1-CmdA',
826
+ correlationId: 'c1',
827
+ commandName: 'CmdA',
828
+ status: 'running',
829
+ pendingCount: 1,
830
+ endedCount: 0,
831
+ });
832
+ await collection.insertOne({
833
+ _id: 'c1-CmdB',
834
+ correlationId: 'c1',
835
+ commandName: 'CmdB',
836
+ status: 'success',
837
+ pendingCount: 0,
838
+ endedCount: 2,
839
+ });
840
+ await collection.insertOne({
841
+ _id: 'c1-CmdC',
842
+ correlationId: 'c1',
843
+ commandName: 'CmdC',
844
+ status: 'error',
845
+ pendingCount: 0,
846
+ endedCount: 1,
847
+ });
848
+
849
+ const result = await readModel.getRunStats('c1');
850
+
851
+ expect(result.nodes).toEqual({
852
+ total: 3,
853
+ running: 1,
854
+ success: 1,
855
+ error: 1,
856
+ });
857
+ });
858
+
859
+ it('should filter by correlationId', async () => {
860
+ const itemCollection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
861
+ await itemCollection.insertOne({
862
+ _id: 'c1-Cmd-a',
863
+ correlationId: 'c1',
864
+ commandType: 'Cmd',
865
+ itemKey: 'a',
866
+ currentRequestId: 'r1',
867
+ status: 'running',
868
+ attemptCount: 1,
869
+ });
870
+ await itemCollection.insertOne({
871
+ _id: 'c2-Cmd-a',
872
+ correlationId: 'c2',
873
+ commandType: 'Cmd',
874
+ itemKey: 'a',
875
+ currentRequestId: 'r2',
876
+ status: 'success',
877
+ attemptCount: 1,
878
+ });
879
+ const nodeCollection = database.collection<WithId<NodeStatusDocument>>('NodeStatus');
880
+ await nodeCollection.insertOne({
881
+ _id: 'c1-Cmd',
882
+ correlationId: 'c1',
883
+ commandName: 'Cmd',
884
+ status: 'running',
885
+ pendingCount: 1,
886
+ endedCount: 0,
887
+ });
888
+ await nodeCollection.insertOne({
889
+ _id: 'c2-Cmd',
890
+ correlationId: 'c2',
891
+ commandName: 'Cmd',
892
+ status: 'success',
893
+ pendingCount: 0,
894
+ endedCount: 1,
895
+ });
896
+
897
+ const result = await readModel.getRunStats('c1');
898
+
899
+ expect(result).toEqual({
900
+ items: { total: 1, running: 1, success: 0, error: 0, retried: 0 },
901
+ nodes: { total: 1, running: 1, success: 0, error: 0 },
902
+ });
903
+ });
904
+
905
+ it('should count idle nodes', async () => {
906
+ const collection = database.collection<WithId<NodeStatusDocument>>('NodeStatus');
907
+ await collection.insertOne({
908
+ _id: 'c1-CmdA',
909
+ correlationId: 'c1',
910
+ commandName: 'CmdA',
911
+ status: 'idle',
912
+ pendingCount: 0,
913
+ endedCount: 0,
914
+ });
915
+ await collection.insertOne({
916
+ _id: 'c1-CmdB',
917
+ correlationId: 'c1',
918
+ commandName: 'CmdB',
919
+ status: 'running',
920
+ pendingCount: 1,
921
+ endedCount: 0,
922
+ });
923
+
924
+ const result = await readModel.getRunStats('c1');
925
+
926
+ expect(result.nodes).toEqual({
927
+ total: 2,
928
+ running: 1,
929
+ success: 0,
930
+ error: 0,
931
+ });
932
+ });
933
+ });
934
+
574
935
  describe('getStats', () => {
575
936
  it('should return zero stats when no messages exist', async () => {
576
937
  const result = await readModel.getStats();
@@ -13,6 +13,11 @@ export interface CommandStats {
13
13
  aggregateStatus: NodeStatus;
14
14
  }
15
15
 
16
+ export interface RunStats {
17
+ items: { total: number; running: number; success: number; error: number; retried: number };
18
+ nodes: { total: number; running: number; success: number; error: number };
19
+ }
20
+
16
21
  export interface MessageStats {
17
22
  totalMessages: number;
18
23
  totalCommands: number;
@@ -115,6 +120,56 @@ export class PipelineReadModel {
115
120
  };
116
121
  }
117
122
 
123
+ async getAllItemStatuses(correlationId: string): Promise<ItemStatusDocument[]> {
124
+ const items = await this.itemStatusCollection.find((doc) => doc.correlationId === correlationId);
125
+ return items.map((item) => ({
126
+ correlationId: item.correlationId,
127
+ commandType: item.commandType,
128
+ itemKey: item.itemKey,
129
+ currentRequestId: item.currentRequestId,
130
+ status: item.status,
131
+ attemptCount: item.attemptCount,
132
+ startedAt: item.startedAt,
133
+ endedAt: item.endedAt,
134
+ }));
135
+ }
136
+
137
+ async getAllNodeStatuses(correlationId: string): Promise<NodeStatusDocument[]> {
138
+ const nodes = await this.nodeStatusCollection.find((doc) => doc.correlationId === correlationId);
139
+ return nodes.map((node) => ({
140
+ correlationId: node.correlationId,
141
+ commandName: node.commandName,
142
+ status: node.status,
143
+ pendingCount: node.pendingCount,
144
+ endedCount: node.endedCount,
145
+ lastDurationMs: node.lastDurationMs,
146
+ }));
147
+ }
148
+
149
+ async getRunStats(correlationId: string): Promise<RunStats> {
150
+ const items = await this.itemStatusCollection.find((doc) => doc.correlationId === correlationId);
151
+ const nodes = await this.nodeStatusCollection.find((doc) => doc.correlationId === correlationId);
152
+
153
+ const itemStats = { total: 0, running: 0, success: 0, error: 0, retried: 0 };
154
+ for (const item of items) {
155
+ itemStats.total++;
156
+ if (item.status === 'running') itemStats.running++;
157
+ else if (item.status === 'success') itemStats.success++;
158
+ else if (item.status === 'error') itemStats.error++;
159
+ if (item.attemptCount > 1) itemStats.retried++;
160
+ }
161
+
162
+ const nodeStats = { total: 0, running: 0, success: 0, error: 0 };
163
+ for (const node of nodes) {
164
+ nodeStats.total++;
165
+ if (node.status === 'running') nodeStats.running++;
166
+ else if (node.status === 'success') nodeStats.success++;
167
+ else if (node.status === 'error') nodeStats.error++;
168
+ }
169
+
170
+ return { items: itemStats, nodes: nodeStats };
171
+ }
172
+
118
173
  async getMessages(correlationId?: string): Promise<MessageLogDocument[]> {
119
174
  if (correlationId) {
120
175
  return this.messageLogCollection.find((doc) => doc.correlationId === correlationId);