@a5c-ai/krate 5.0.1-staging.0d89dd7c4 → 5.0.1-staging.2d6ea2736

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.
@@ -19,7 +19,7 @@
19
19
  "platformNamespace": "krate-org-default"
20
20
  }
21
21
  ],
22
- "generatedAt": "2026-05-16T09:44:55.208Z",
22
+ "generatedAt": "2026-05-17T09:58:34.998Z",
23
23
  "correlationId": null,
24
24
  "controller": {
25
25
  "mode": "krate-workspace",
@@ -583,7 +583,7 @@
583
583
  "maintainers"
584
584
  ],
585
585
  "invitedBy": "admin",
586
- "expiresAt": "2026-05-23T09:44:55.205Z"
586
+ "expiresAt": "2026-05-24T09:58:34.995Z"
587
587
  },
588
588
  "status": {
589
589
  "phase": "Pending",
@@ -595,7 +595,7 @@
595
595
  "Pending": 1
596
596
  },
597
597
  "storage": "etcd",
598
- "yaml": "apiVersion: krate.a5c.ai/v1alpha1\nkind: Invite\nmetadata:\n namespace: krate-org-default\n labels:\n role: member\n annotations:\n name: new-user-example-com\n resourceVersion: 1\nspec:\n organizationRef: default\n email: new-user@example.com\n role: member\n teams:\n - maintainers\n invitedBy: admin\n expiresAt: 2026-05-23T09:44:55.205Z\nstatus:\n phase: Pending\n storage: etcd\n",
598
+ "yaml": "apiVersion: krate.a5c.ai/v1alpha1\nkind: Invite\nmetadata:\n namespace: krate-org-default\n labels:\n role: member\n annotations:\n name: new-user-example-com\n resourceVersion: 1\nspec:\n organizationRef: default\n email: new-user@example.com\n role: member\n teams:\n - maintainers\n invitedBy: admin\n expiresAt: 2026-05-24T09:58:34.995Z\nstatus:\n phase: Pending\n storage: etcd\n",
599
599
  "action": {
600
600
  "list": "Open Invite records in krate-org-default",
601
601
  "watch": "Watch Invite updates in krate-org-default",
@@ -2476,7 +2476,7 @@
2476
2476
  "maintainers"
2477
2477
  ],
2478
2478
  "phase": "Pending",
2479
- "expiresAt": "2026-05-23T09:44:55.205Z"
2479
+ "expiresAt": "2026-05-24T09:58:34.995Z"
2480
2480
  }
2481
2481
  ],
2482
2482
  "mappings": [
@@ -3082,7 +3082,7 @@
3082
3082
  "maintainers"
3083
3083
  ],
3084
3084
  "phase": "Pending",
3085
- "expiresAt": "2026-05-23T09:44:55.205Z"
3085
+ "expiresAt": "2026-05-24T09:58:34.995Z"
3086
3086
  }
3087
3087
  ],
3088
3088
  "mappings": [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "project": "Krate",
3
3
  "version": "0.1.0",
4
- "generatedAt": "2026-05-16T09:44:55.215Z",
4
+ "generatedAt": "2026-05-17T09:58:35.006Z",
5
5
  "status": "ready-for-local-development",
6
6
  "components": [
7
7
  {
@@ -465,7 +465,7 @@
465
465
  "maintainers"
466
466
  ],
467
467
  "invitedBy": "admin",
468
- "expiresAt": "2026-05-23T09:44:55.205Z"
468
+ "expiresAt": "2026-05-24T09:58:34.995Z"
469
469
  },
470
470
  "status": {
471
471
  "phase": "Pending",
@@ -547,7 +547,7 @@
547
547
  },
548
548
  "auditLog": [
549
549
  {
550
- "at": "2026-05-16T09:44:55.203Z",
550
+ "at": "2026-05-17T09:58:34.994Z",
551
551
  "operation": "create",
552
552
  "user": "admin@example.com",
553
553
  "groups": [
@@ -559,7 +559,7 @@
559
559
  "allowed": true
560
560
  },
561
561
  {
562
- "at": "2026-05-16T09:44:55.204Z",
562
+ "at": "2026-05-17T09:58:34.995Z",
563
563
  "operation": "create",
564
564
  "user": "admin@example.com",
565
565
  "groups": [
@@ -571,7 +571,7 @@
571
571
  "allowed": true
572
572
  },
573
573
  {
574
- "at": "2026-05-16T09:44:55.204Z",
574
+ "at": "2026-05-17T09:58:34.995Z",
575
575
  "operation": "create",
576
576
  "user": "admin@example.com",
577
577
  "groups": [
@@ -583,7 +583,7 @@
583
583
  "allowed": true
584
584
  },
585
585
  {
586
- "at": "2026-05-16T09:44:55.204Z",
586
+ "at": "2026-05-17T09:58:34.995Z",
587
587
  "operation": "create",
588
588
  "user": "platform@example.com",
589
589
  "groups": [
@@ -595,7 +595,7 @@
595
595
  "allowed": true
596
596
  },
597
597
  {
598
- "at": "2026-05-16T09:44:55.204Z",
598
+ "at": "2026-05-17T09:58:34.995Z",
599
599
  "operation": "create",
600
600
  "user": "admin@example.com",
601
601
  "groups": [
@@ -607,7 +607,7 @@
607
607
  "allowed": true
608
608
  },
609
609
  {
610
- "at": "2026-05-16T09:44:55.204Z",
610
+ "at": "2026-05-17T09:58:34.995Z",
611
611
  "operation": "create",
612
612
  "user": "platform@example.com",
613
613
  "groups": [
@@ -619,7 +619,7 @@
619
619
  "allowed": true
620
620
  },
621
621
  {
622
- "at": "2026-05-16T09:44:55.205Z",
622
+ "at": "2026-05-17T09:58:34.995Z",
623
623
  "operation": "create",
624
624
  "user": "platform@example.com",
625
625
  "groups": [
@@ -631,7 +631,7 @@
631
631
  "allowed": true
632
632
  },
633
633
  {
634
- "at": "2026-05-16T09:44:55.205Z",
634
+ "at": "2026-05-17T09:58:34.995Z",
635
635
  "operation": "create",
636
636
  "user": "platform@example.com",
637
637
  "groups": [
@@ -643,7 +643,7 @@
643
643
  "allowed": true
644
644
  },
645
645
  {
646
- "at": "2026-05-16T09:44:55.205Z",
646
+ "at": "2026-05-17T09:58:34.996Z",
647
647
  "operation": "create",
648
648
  "user": "admin@example.com",
649
649
  "groups": [
@@ -655,7 +655,7 @@
655
655
  "allowed": true
656
656
  },
657
657
  {
658
- "at": "2026-05-16T09:44:55.205Z",
658
+ "at": "2026-05-17T09:58:34.996Z",
659
659
  "operation": "create",
660
660
  "user": "platform@example.com",
661
661
  "groups": [
@@ -667,7 +667,7 @@
667
667
  "allowed": true
668
668
  },
669
669
  {
670
- "at": "2026-05-16T09:44:55.205Z",
670
+ "at": "2026-05-17T09:58:34.996Z",
671
671
  "operation": "create",
672
672
  "user": "platform@example.com",
673
673
  "groups": [
@@ -915,7 +915,7 @@
915
915
  }
916
916
  },
917
917
  "audit": {
918
- "at": "2026-05-16T09:44:55.203Z",
918
+ "at": "2026-05-17T09:58:34.994Z",
919
919
  "operation": "create",
920
920
  "user": "admin@example.com",
921
921
  "groups": [
@@ -956,7 +956,7 @@
956
956
  }
957
957
  },
958
958
  "audit": {
959
- "at": "2026-05-16T09:44:55.204Z",
959
+ "at": "2026-05-17T09:58:34.995Z",
960
960
  "operation": "create",
961
961
  "user": "admin@example.com",
962
962
  "groups": [
@@ -992,7 +992,7 @@
992
992
  }
993
993
  },
994
994
  "audit": {
995
- "at": "2026-05-16T09:44:55.204Z",
995
+ "at": "2026-05-17T09:58:34.995Z",
996
996
  "operation": "create",
997
997
  "user": "admin@example.com",
998
998
  "groups": [
@@ -1037,7 +1037,7 @@
1037
1037
  }
1038
1038
  },
1039
1039
  "audit": {
1040
- "at": "2026-05-16T09:44:55.204Z",
1040
+ "at": "2026-05-17T09:58:34.995Z",
1041
1041
  "operation": "create",
1042
1042
  "user": "platform@example.com",
1043
1043
  "groups": [
@@ -1081,7 +1081,7 @@
1081
1081
  }
1082
1082
  },
1083
1083
  "audit": {
1084
- "at": "2026-05-16T09:44:55.204Z",
1084
+ "at": "2026-05-17T09:58:34.995Z",
1085
1085
  "operation": "create",
1086
1086
  "user": "admin@example.com",
1087
1087
  "groups": [
@@ -1129,7 +1129,7 @@
1129
1129
  }
1130
1130
  },
1131
1131
  "audit": {
1132
- "at": "2026-05-16T09:44:55.204Z",
1132
+ "at": "2026-05-17T09:58:34.995Z",
1133
1133
  "operation": "create",
1134
1134
  "user": "platform@example.com",
1135
1135
  "groups": [
@@ -1179,7 +1179,7 @@
1179
1179
  }
1180
1180
  },
1181
1181
  "audit": {
1182
- "at": "2026-05-16T09:44:55.205Z",
1182
+ "at": "2026-05-17T09:58:34.995Z",
1183
1183
  "operation": "create",
1184
1184
  "user": "platform@example.com",
1185
1185
  "groups": [
@@ -1230,7 +1230,7 @@
1230
1230
  }
1231
1231
  },
1232
1232
  "audit": {
1233
- "at": "2026-05-16T09:44:55.205Z",
1233
+ "at": "2026-05-17T09:58:34.995Z",
1234
1234
  "operation": "create",
1235
1235
  "user": "platform@example.com",
1236
1236
  "groups": [
@@ -1265,7 +1265,7 @@
1265
1265
  "maintainers"
1266
1266
  ],
1267
1267
  "invitedBy": "admin",
1268
- "expiresAt": "2026-05-23T09:44:55.205Z"
1268
+ "expiresAt": "2026-05-24T09:58:34.995Z"
1269
1269
  },
1270
1270
  "status": {
1271
1271
  "phase": "Pending",
@@ -1273,7 +1273,7 @@
1273
1273
  }
1274
1274
  },
1275
1275
  "audit": {
1276
- "at": "2026-05-16T09:44:55.205Z",
1276
+ "at": "2026-05-17T09:58:34.996Z",
1277
1277
  "operation": "create",
1278
1278
  "user": "admin@example.com",
1279
1279
  "groups": [
@@ -1324,7 +1324,7 @@
1324
1324
  }
1325
1325
  },
1326
1326
  "audit": {
1327
- "at": "2026-05-16T09:44:55.205Z",
1327
+ "at": "2026-05-17T09:58:34.996Z",
1328
1328
  "operation": "create",
1329
1329
  "user": "platform@example.com",
1330
1330
  "groups": [
@@ -1375,7 +1375,7 @@
1375
1375
  }
1376
1376
  },
1377
1377
  "audit": {
1378
- "at": "2026-05-16T09:44:55.205Z",
1378
+ "at": "2026-05-17T09:58:34.996Z",
1379
1379
  "operation": "create",
1380
1380
  "user": "platform@example.com",
1381
1381
  "groups": [
@@ -1810,7 +1810,7 @@
1810
1810
  "maintainers"
1811
1811
  ],
1812
1812
  "invitedBy": "admin",
1813
- "expiresAt": "2026-05-23T09:44:55.205Z"
1813
+ "expiresAt": "2026-05-24T09:58:34.995Z"
1814
1814
  },
1815
1815
  "status": {
1816
1816
  "phase": "Pending",
@@ -2515,7 +2515,7 @@
2515
2515
  }
2516
2516
  },
2517
2517
  "audit": {
2518
- "at": "2026-05-16T09:44:55.203Z",
2518
+ "at": "2026-05-17T09:58:34.994Z",
2519
2519
  "operation": "create",
2520
2520
  "user": "admin@example.com",
2521
2521
  "groups": [
@@ -2556,7 +2556,7 @@
2556
2556
  }
2557
2557
  },
2558
2558
  "audit": {
2559
- "at": "2026-05-16T09:44:55.204Z",
2559
+ "at": "2026-05-17T09:58:34.995Z",
2560
2560
  "operation": "create",
2561
2561
  "user": "admin@example.com",
2562
2562
  "groups": [
@@ -2592,7 +2592,7 @@
2592
2592
  }
2593
2593
  },
2594
2594
  "audit": {
2595
- "at": "2026-05-16T09:44:55.204Z",
2595
+ "at": "2026-05-17T09:58:34.995Z",
2596
2596
  "operation": "create",
2597
2597
  "user": "admin@example.com",
2598
2598
  "groups": [
@@ -2637,7 +2637,7 @@
2637
2637
  }
2638
2638
  },
2639
2639
  "audit": {
2640
- "at": "2026-05-16T09:44:55.204Z",
2640
+ "at": "2026-05-17T09:58:34.995Z",
2641
2641
  "operation": "create",
2642
2642
  "user": "platform@example.com",
2643
2643
  "groups": [
@@ -2681,7 +2681,7 @@
2681
2681
  }
2682
2682
  },
2683
2683
  "audit": {
2684
- "at": "2026-05-16T09:44:55.204Z",
2684
+ "at": "2026-05-17T09:58:34.995Z",
2685
2685
  "operation": "create",
2686
2686
  "user": "admin@example.com",
2687
2687
  "groups": [
@@ -2729,7 +2729,7 @@
2729
2729
  }
2730
2730
  },
2731
2731
  "audit": {
2732
- "at": "2026-05-16T09:44:55.204Z",
2732
+ "at": "2026-05-17T09:58:34.995Z",
2733
2733
  "operation": "create",
2734
2734
  "user": "platform@example.com",
2735
2735
  "groups": [
@@ -2779,7 +2779,7 @@
2779
2779
  }
2780
2780
  },
2781
2781
  "audit": {
2782
- "at": "2026-05-16T09:44:55.205Z",
2782
+ "at": "2026-05-17T09:58:34.995Z",
2783
2783
  "operation": "create",
2784
2784
  "user": "platform@example.com",
2785
2785
  "groups": [
@@ -2830,7 +2830,7 @@
2830
2830
  }
2831
2831
  },
2832
2832
  "audit": {
2833
- "at": "2026-05-16T09:44:55.205Z",
2833
+ "at": "2026-05-17T09:58:34.995Z",
2834
2834
  "operation": "create",
2835
2835
  "user": "platform@example.com",
2836
2836
  "groups": [
@@ -2865,7 +2865,7 @@
2865
2865
  "maintainers"
2866
2866
  ],
2867
2867
  "invitedBy": "admin",
2868
- "expiresAt": "2026-05-23T09:44:55.205Z"
2868
+ "expiresAt": "2026-05-24T09:58:34.995Z"
2869
2869
  },
2870
2870
  "status": {
2871
2871
  "phase": "Pending",
@@ -2873,7 +2873,7 @@
2873
2873
  }
2874
2874
  },
2875
2875
  "audit": {
2876
- "at": "2026-05-16T09:44:55.205Z",
2876
+ "at": "2026-05-17T09:58:34.996Z",
2877
2877
  "operation": "create",
2878
2878
  "user": "admin@example.com",
2879
2879
  "groups": [
@@ -2924,7 +2924,7 @@
2924
2924
  }
2925
2925
  },
2926
2926
  "audit": {
2927
- "at": "2026-05-16T09:44:55.205Z",
2927
+ "at": "2026-05-17T09:58:34.996Z",
2928
2928
  "operation": "create",
2929
2929
  "user": "platform@example.com",
2930
2930
  "groups": [
@@ -2975,7 +2975,7 @@
2975
2975
  }
2976
2976
  },
2977
2977
  "audit": {
2978
- "at": "2026-05-16T09:44:55.205Z",
2978
+ "at": "2026-05-17T09:58:34.996Z",
2979
2979
  "operation": "create",
2980
2980
  "user": "platform@example.com",
2981
2981
  "groups": [
@@ -2990,7 +2990,7 @@
2990
2990
  ],
2991
2991
  "auditLog": [
2992
2992
  {
2993
- "at": "2026-05-16T09:44:55.203Z",
2993
+ "at": "2026-05-17T09:58:34.994Z",
2994
2994
  "operation": "create",
2995
2995
  "user": "admin@example.com",
2996
2996
  "groups": [
@@ -3002,7 +3002,7 @@
3002
3002
  "allowed": true
3003
3003
  },
3004
3004
  {
3005
- "at": "2026-05-16T09:44:55.204Z",
3005
+ "at": "2026-05-17T09:58:34.995Z",
3006
3006
  "operation": "create",
3007
3007
  "user": "admin@example.com",
3008
3008
  "groups": [
@@ -3014,7 +3014,7 @@
3014
3014
  "allowed": true
3015
3015
  },
3016
3016
  {
3017
- "at": "2026-05-16T09:44:55.204Z",
3017
+ "at": "2026-05-17T09:58:34.995Z",
3018
3018
  "operation": "create",
3019
3019
  "user": "admin@example.com",
3020
3020
  "groups": [
@@ -3026,7 +3026,7 @@
3026
3026
  "allowed": true
3027
3027
  },
3028
3028
  {
3029
- "at": "2026-05-16T09:44:55.204Z",
3029
+ "at": "2026-05-17T09:58:34.995Z",
3030
3030
  "operation": "create",
3031
3031
  "user": "platform@example.com",
3032
3032
  "groups": [
@@ -3038,7 +3038,7 @@
3038
3038
  "allowed": true
3039
3039
  },
3040
3040
  {
3041
- "at": "2026-05-16T09:44:55.204Z",
3041
+ "at": "2026-05-17T09:58:34.995Z",
3042
3042
  "operation": "create",
3043
3043
  "user": "admin@example.com",
3044
3044
  "groups": [
@@ -3050,7 +3050,7 @@
3050
3050
  "allowed": true
3051
3051
  },
3052
3052
  {
3053
- "at": "2026-05-16T09:44:55.204Z",
3053
+ "at": "2026-05-17T09:58:34.995Z",
3054
3054
  "operation": "create",
3055
3055
  "user": "platform@example.com",
3056
3056
  "groups": [
@@ -3062,7 +3062,7 @@
3062
3062
  "allowed": true
3063
3063
  },
3064
3064
  {
3065
- "at": "2026-05-16T09:44:55.205Z",
3065
+ "at": "2026-05-17T09:58:34.995Z",
3066
3066
  "operation": "create",
3067
3067
  "user": "platform@example.com",
3068
3068
  "groups": [
@@ -3074,7 +3074,7 @@
3074
3074
  "allowed": true
3075
3075
  },
3076
3076
  {
3077
- "at": "2026-05-16T09:44:55.205Z",
3077
+ "at": "2026-05-17T09:58:34.995Z",
3078
3078
  "operation": "create",
3079
3079
  "user": "platform@example.com",
3080
3080
  "groups": [
@@ -3086,7 +3086,7 @@
3086
3086
  "allowed": true
3087
3087
  },
3088
3088
  {
3089
- "at": "2026-05-16T09:44:55.205Z",
3089
+ "at": "2026-05-17T09:58:34.996Z",
3090
3090
  "operation": "create",
3091
3091
  "user": "admin@example.com",
3092
3092
  "groups": [
@@ -3098,7 +3098,7 @@
3098
3098
  "allowed": true
3099
3099
  },
3100
3100
  {
3101
- "at": "2026-05-16T09:44:55.205Z",
3101
+ "at": "2026-05-17T09:58:34.996Z",
3102
3102
  "operation": "create",
3103
3103
  "user": "platform@example.com",
3104
3104
  "groups": [
@@ -3110,7 +3110,7 @@
3110
3110
  "allowed": true
3111
3111
  },
3112
3112
  {
3113
- "at": "2026-05-16T09:44:55.205Z",
3113
+ "at": "2026-05-17T09:58:34.996Z",
3114
3114
  "operation": "create",
3115
3115
  "user": "platform@example.com",
3116
3116
  "groups": [
@@ -3,7 +3,7 @@
3
3
  "description": "Kubernetes-native forge runtime with Argo CD and Krate-managed repository hosting",
4
4
  "package": {
5
5
  "name": "@a5c-ai/krate",
6
- "version": "5.0.1-staging.0d89dd7c4",
6
+ "version": "5.0.1-staging.2d6ea2736",
7
7
  "private": true
8
8
  },
9
9
  "entrypoints": {
@@ -162,7 +162,7 @@
162
162
  "lifecycle": {
163
163
  "project": "Krate",
164
164
  "version": "0.1.0",
165
- "generatedAt": "2026-05-16T09:44:55.215Z",
165
+ "generatedAt": "2026-05-17T09:58:35.006Z",
166
166
  "status": "ready-for-local-development",
167
167
  "components": [
168
168
  {
@@ -464,7 +464,7 @@
464
464
  "ok": true,
465
465
  "assertions": []
466
466
  },
467
- "generatedAt": "2026-05-16T09:44:55.215Z",
467
+ "generatedAt": "2026-05-17T09:58:35.006Z",
468
468
  "controller": {
469
469
  "status": "degraded",
470
470
  "namespace": "krate-org-default",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a5c-ai/krate",
3
- "version": "5.0.1-staging.0d89dd7c4",
3
+ "version": "5.0.1-staging.2d6ea2736",
4
4
  "description": "a5c.ai Krate: Kubernetes-native forge runtime with Argo CD GitOps and Gitea-backed Git hosting.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -37,6 +37,7 @@ const required = [
37
37
  'apps/web/app/api/auth/callback/[provider]/route.js',
38
38
  'apps/web/app/api/auth/logout/route.js',
39
39
  'apps/web/app/api/auth/delegated/route.js',
40
+ 'apps/web/app/api/orgs/[org]/agents/events/stream/route.js',
40
41
  'src/api-controller.js',
41
42
  'src/kubernetes-resource-gateway.js',
42
43
  'src/kubernetes-controller.js',
@@ -104,7 +105,7 @@ for (const token of ['export function proxy', 'NextResponse.redirect', 'KRATE_AU
104
105
  for (const token of ['spawnSync', 'spawn(', 'kubectl', 'getControllerSnapshot', 'listResource', 'getResource', 'applyResource', 'deleteResource', 'createRepository', 'watchResource', 'auth', 'can-i']) {
105
106
  if (!files['src/kubernetes-controller.js'].includes(token)) failures.push(`kubernetes controller missing ${token}`);
106
107
  }
107
- for (const token of ['createKubernetesResourceGateway', 'controller.snapshot()', 'createControllerUiModel']) {
108
+ for (const token of ['getControllerSnapshotAsync', 'fallbackSnapshot', 'controller.snapshot()', 'createControllerUiModel']) {
108
109
  if (!files['src/controller-client.js'].includes(token)) failures.push(`controller client missing ${token}`);
109
110
  }
110
111
  for (const token of ['createKrateApiController', 'resourceGateway', 'withArchitecture', 'krate-api-controller', 'kubernetes-resource-gateway', 'kubernetes-resource-client', 'git-data-plane', 'never owns Kubernetes reconciliation loops', 'KRATE_API_CONTROLLER_BOUNDARY', 'listRepositoriesForForge', 'getRepositoryForgeView', 'krate-kubernetes-reconciler']) {
@@ -133,8 +134,18 @@ for (const token of ['DegradedBanner', 'No repositories are available yet.', 'No
133
134
  for (const token of ['KrateControllerRecovery', 'KrateLoadingView', 'KRATE_LOADING_MESSAGES', '/api/controller', 'setRecovered(true)', 'router.refresh()', 'sessionStorage']) {
134
135
  if (!(webUiSource() + files['apps/web/app/components/krate-loading.jsx']).includes(token)) failures.push(`recovery loading UI missing ${token}`);
135
136
  }
137
+ for (const token of ['KrateRouteLoadingOverlay', 'krate-route-loading-refresh']) {
138
+ if ((webUiSource() + files['apps/web/app/components/krate-loading.jsx']).includes(token)) failures.push(`route transitions must not render recovery loading UI token ${token}`);
139
+ }
140
+ if (!files['apps/web/app/loading.jsx'].includes('routeLoading') || !files['apps/web/app/loading.jsx'].includes('krateLoadingBar animated')) failures.push('route loading UI must render immediate non-overlay loading feedback');
141
+ if (files['apps/web/app/loading.jsx'].includes('KrateDelayedRouteLoading') || files['apps/web/app/loading.jsx'].includes('return null')) failures.push('route loading UI must not delay or render a blank fallback');
142
+ if (!files['apps/web/app/globals.css'].includes('krateRouteLoadingProgress') || !files['apps/web/app/globals.css'].includes('krateRouteLoadingPhase')) failures.push('route loading UI must animate progress and phase text without client hydration');
143
+ if (!files['apps/web/app/lib/krate-ui.jsx'].includes('useCache: true') || files['apps/web/app/lib/krate-ui.jsx'].includes('useCache: false')) failures.push('Krate page loader must use cached controller snapshots');
136
144
  if ((webUiSource() + files['apps/web/app/components/krate-loading.jsx']).includes('Krate workspace degraded or empty')) failures.push('degraded workspace copy should be replaced by recovery loading UI');
137
145
  if ((webUiSource() + files['apps/web/app/components/krate-loading.jsx']).includes('window.location.reload')) failures.push('recovery loading UI must not reload the page');
146
+ for (const token of ['text/event-stream', 'globalEventBus', 'KRATE_CONTROLLER_URL', "type: 'connected'"]) {
147
+ if (!files['apps/web/app/api/orgs/[org]/agents/events/stream/route.js'].includes(token)) failures.push(`events stream route missing ${token}`);
148
+ }
138
149
  if (!/\.krateRecoveryOverlay\s*\{[\s\S]*?position:\s*fixed;[\s\S]*?inset:\s*0;/.test(files['apps/web/app/globals.css'])) failures.push('recovery loading UI must be fixed overlay');
139
150
  for (const token of ['duplex', 'KRATE_GITEA_HTTP_URL', 'fetch(target', 'degraded']) {
140
151
  if (!files['apps/web/app/api/git-proxy/route.js'].includes(token)) failures.push(`git proxy route missing ${token}`);
@@ -1,72 +1,112 @@
1
- import { createControllerUiModel } from './controller-ui.js';
2
- import { createKrateApiController } from './api-controller.js';
3
- import { createKubernetesResourceGateway } from './kubernetes-resource-gateway.js';
4
- import { clearSnapshotCache, staleWhileRevalidate } from './snapshot-cache.js';
1
+ import { createControllerUiModel } from './controller-ui.js';
2
+ import { clearSnapshotCache, staleWhileRevalidate } from './snapshot-cache.js';
5
3
  import { getControllerSnapshotAsync } from './kubernetes-controller-async.js';
6
4
 
7
5
  export { clearSnapshotCache };
8
6
 
9
7
  const CONTROLLER_REQUEST_TIMEOUT_MS = Number(process.env.KRATE_CONTROLLER_REQUEST_TIMEOUT_MS || 5_000);
10
8
 
11
- export async function fetchControllerUiModel({ controllerUrl = process.env.KRATE_CONTROLLER_URL, fetchImpl = globalThis.fetch, controller = createKrateApiController({ resourceGateway: createKubernetesResourceGateway() }), organization = process.env.KRATE_ORG || null, localFallback = true, requestTimeoutMs = CONTROLLER_REQUEST_TIMEOUT_MS, useCache = true, swrOptions = {} } = {}) {
12
- const revalidateFn = async () => {
13
- if (controllerUrl) {
14
- try {
15
- const target = new URL('/api/controller', controllerUrl);
16
- if (organization) target.searchParams.set('org', organization);
9
+ export async function fetchControllerUiModel({ controllerUrl = process.env.KRATE_CONTROLLER_URL, fetchImpl = globalThis.fetch, controller = null, organization = process.env.KRATE_ORG || null, localFallback = true, requestTimeoutMs = CONTROLLER_REQUEST_TIMEOUT_MS, useCache = true, swrOptions = {}, fallbackSnapshot = getControllerSnapshotAsync } = {}) {
10
+ const revalidateFn = async () => {
11
+ if (controllerUrl) {
12
+ try {
13
+ const target = new URL('/api/controller', controllerUrl);
14
+ if (organization) target.searchParams.set('org', organization);
17
15
  const signal = requestTimeoutMs > 0 && globalThis.AbortSignal?.timeout ? AbortSignal.timeout(requestTimeoutMs) : undefined;
18
16
  const response = await fetchImpl(target, { cache: 'no-store', ...(signal ? { signal } : {}) });
19
- if (!response.ok) throw new Error(`controller API ${response.status}`);
20
- return await response.json();
21
- } catch (error) {
22
- return createControllerUiModel({
23
- source: 'kubernetes',
24
- namespace: process.env.KRATE_NAMESPACE || 'krate-system',
25
- kubectl: { available: false, context: null, errors: [error.message] },
26
- resources: {}, crds: [], events: [], permissions: [], storage: {}, commands: []
27
- }, { organization });
28
- }
29
- }
30
- if (!localFallback) return unavailableControllerModel('KRATE_CONTROLLER_URL is not configured', organization);
31
- return fallbackControllerModel(controller, null, organization);
32
- };
17
+ if (!response.ok) throw new Error(`controller API ${response.status}`);
18
+ const remoteModel = await response.json();
19
+ if (localFallback && shouldFallbackFromRemoteModel(remoteModel)) {
20
+ return fallbackControllerModel({ controller, connectionError: new Error(remoteControllerError(remoteModel) || 'controller returned degraded empty data'), organization, fallbackSnapshot });
21
+ }
22
+ if (localFallback && shouldProbeLocalModel(remoteModel)) {
23
+ const localModel = await fallbackControllerModel({ controller, organization, fallbackSnapshot });
24
+ if (modelResourceScore(localModel) > modelResourceScore(remoteModel)) return localModel;
25
+ }
26
+ return remoteModel;
27
+ } catch (error) {
28
+ if (localFallback) return fallbackControllerModel({ controller, connectionError: error, organization, fallbackSnapshot });
29
+ return unavailableControllerModel(error.message, organization);
30
+ }
31
+ }
32
+ if (!localFallback) return unavailableControllerModel('KRATE_CONTROLLER_URL is not configured', organization);
33
+ return fallbackControllerModel({ controller, organization, fallbackSnapshot });
34
+ };
33
35
 
34
36
  if (!useCache) return revalidateFn();
35
37
  return staleWhileRevalidate(organization, revalidateFn, swrOptions);
36
- }
37
-
38
- async function fallbackControllerModel(controller, connectionError = null, organization = null) {
39
- try {
40
- const snapshot = await getControllerSnapshotAsync().catch(() => controller.snapshot());
41
- const model = createControllerUiModel(snapshot, { organization });
42
- if (connectionError) model.controller.connection.errors = [connectionError.message, ...(model.controller.connection.errors || [])];
43
- return model;
44
- } catch (error) {
45
- return createControllerUiModel({
46
- source: 'kubernetes',
47
- namespace: process.env.KRATE_NAMESPACE || 'krate-system',
48
- kubectl: { available: false, context: null, errors: [connectionError?.message, error.message].filter(Boolean) },
49
- resources: {},
50
- crds: [],
51
- events: [],
52
- permissions: [],
53
- storage: {},
54
- commands: []
55
- }, { organization });
56
- }
57
- }
58
-
59
- function unavailableControllerModel(messages, organization = null) {
60
- const errors = Array.isArray(messages) ? messages : [messages];
61
- return createControllerUiModel({
62
- source: 'kubernetes',
63
- namespace: process.env.KRATE_NAMESPACE || 'krate-system',
64
- kubectl: { available: false, context: null, errors: errors.filter(Boolean) },
65
- resources: {},
66
- crds: [],
67
- events: [],
68
- permissions: [],
69
- storage: {},
70
- commands: []
71
- }, { organization });
72
- }
38
+ }
39
+
40
+ async function fallbackControllerModel({ controller = null, connectionError = null, organization = null, fallbackSnapshot = getControllerSnapshotAsync } = {}) {
41
+ try {
42
+ const snapshot = controller ? await controller.snapshot() : await fallbackSnapshot();
43
+ const model = createControllerUiModel(snapshot, { organization });
44
+ if (connectionError) model.controller.connection.errors = [connectionError.message, ...(model.controller.connection.errors || [])];
45
+ return model;
46
+ } catch (error) {
47
+ return createControllerUiModel({
48
+ source: 'kubernetes',
49
+ namespace: process.env.KRATE_NAMESPACE || 'krate-system',
50
+ kubectl: { available: false, context: null, errors: [connectionError?.message, error.message].filter(Boolean) },
51
+ resources: {},
52
+ crds: [],
53
+ events: [],
54
+ permissions: [],
55
+ storage: {},
56
+ commands: []
57
+ }, { organization });
58
+ }
59
+ }
60
+
61
+
62
+ function shouldProbeLocalModel(model) {
63
+ if (!model || model.status !== 'ready') return false;
64
+ const hasLiveConnection = Boolean(model.controller?.connection?.available || model.controller?.apiService);
65
+ if (!hasLiveConnection) return false;
66
+ const summaries = Array.isArray(model.resources) ? model.resources : [];
67
+ const crdKinds = new Set(['Repository', 'RunnerPool', 'Pipeline', 'Job']);
68
+ const crdItems = summaries
69
+ .filter((resource) => crdKinds.has(resource?.kind))
70
+ .reduce((count, resource) => count + Number(resource?.count || resource?.items?.length || 0), 0);
71
+ return crdItems === 0;
72
+ }
73
+
74
+ function modelResourceScore(model) {
75
+ if (!model) return 0;
76
+ const metricCount = Number(model.metrics?.resources || 0);
77
+ const summaryCount = Array.isArray(model.resources)
78
+ ? model.resources.reduce((count, resource) => count + Number(resource?.count || resource?.items?.length || 0), 0)
79
+ : 0;
80
+ const dashboardCount = Number(model.views?.dashboard?.repositories?.length || 0);
81
+ return metricCount + summaryCount + dashboardCount;
82
+ }
83
+
84
+ function shouldFallbackFromRemoteModel(model) {
85
+ if (!model || model.status !== 'degraded') return false;
86
+ const hasLiveConnection = Boolean(model.controller?.connection?.available || model.controller?.apiService);
87
+ if (hasLiveConnection) return false;
88
+ const resourceCount = Number(model.metrics?.resources || 0);
89
+ const hasResourceItems = Array.isArray(model.resources) && model.resources.some((resource) => Number(resource?.count || 0) > 0 || resource?.items?.length);
90
+ const hasDashboardItems = Number(model.views?.dashboard?.repositories?.length || 0) > 0;
91
+ const errors = model.controller?.connection?.errors || [];
92
+ return resourceCount === 0 && !hasResourceItems && !hasDashboardItems && errors.length > 0;
93
+ }
94
+
95
+ function remoteControllerError(model) {
96
+ return (model?.controller?.connection?.errors || []).filter(Boolean).join('; ');
97
+ }
98
+
99
+ function unavailableControllerModel(messages, organization = null) {
100
+ const errors = Array.isArray(messages) ? messages : [messages];
101
+ return createControllerUiModel({
102
+ source: 'kubernetes',
103
+ namespace: process.env.KRATE_NAMESPACE || 'krate-system',
104
+ kubectl: { available: false, context: null, errors: errors.filter(Boolean) },
105
+ resources: {},
106
+ crds: [],
107
+ events: [],
108
+ permissions: [],
109
+ storage: {},
110
+ commands: []
111
+ }, { organization });
112
+ }
@@ -264,13 +264,14 @@ function filterResourceItemsForOrg(definition, items = [], org) {
264
264
  return filterByOrg(items, org);
265
265
  }
266
266
 
267
- function filterByOrg(items = [], org) {
268
- if (!org) return items;
269
- return items.filter((item) => {
270
- const itemOrg = item.spec?.organizationRef || item.metadata?.labels?.[KRATE_ORG_LABEL];
271
- return itemOrg === org;
272
- });
273
- }
267
+ function filterByOrg(items = [], org) {
268
+ if (!org) return items;
269
+ const orgNamespace = orgNamespaceName(org);
270
+ return items.filter((item) => {
271
+ const itemOrg = item.spec?.organizationRef || item.metadata?.labels?.[KRATE_ORG_LABEL];
272
+ return itemOrg === org || item.metadata?.namespace === orgNamespace;
273
+ });
274
+ }
274
275
 
275
276
  function normalizeSnapshot(source = {}) {
276
277
  const raw = typeof source.snapshot === 'function' ? source.snapshot() : source;
@@ -53,6 +53,19 @@ function kubectlArgs(args, env) {
53
53
  return extra ? [...extra, ...args] : args;
54
54
  }
55
55
 
56
+ function currentContextResult(env) {
57
+ if (!inClusterArgs(env)) return null;
58
+ return {
59
+ ok: true,
60
+ status: 0,
61
+ signal: null,
62
+ stdout: 'in-cluster\n',
63
+ stderr: '',
64
+ error: null,
65
+ command: 'kubectl config current-context'
66
+ };
67
+ }
68
+
56
69
  // ---------------------------------------------------------------------------
57
70
  // Low-level async kubectl runner
58
71
  // ---------------------------------------------------------------------------
@@ -155,8 +168,9 @@ export async function getControllerSnapshotAsync(options = {}) {
155
168
 
156
169
  try {
157
170
  // Phase 1: context + version in parallel
171
+ const inClusterContext = currentContextResult(env);
158
172
  const [contextResult, versionResult] = await Promise.all([
159
- runKubectlAsync(['config', 'current-context'], { kubectl, timeoutMs, env, allowFailure: true }),
173
+ inClusterContext || runKubectlAsync(['config', 'current-context'], { kubectl, timeoutMs, env, allowFailure: true }),
160
174
  runKubectlAsync(['version', '--client=true', '-o', 'json'], { kubectl, timeoutMs, env, allowFailure: true })
161
175
  ]);
162
176
 
@@ -198,7 +212,7 @@ export async function getControllerSnapshotAsync(options = {}) {
198
212
  // Fetch platform-scoped resources first so we can derive org namespaces
199
213
  const platformResults = await Promise.all(
200
214
  platformScopedDefs
201
- .filter((d) => discoveredPluralSet.has(`${d.group || KRATE_API_GROUP}/${d.plural}`))
215
+ .filter((d) => shouldListSnapshotDefinition(d, discoveredPluralSet))
202
216
  .map(async (definition) => {
203
217
  const resourceNamespace = definition.namespace || namespace;
204
218
  const result = await runKubectlAsync(
@@ -218,7 +232,7 @@ export async function getControllerSnapshotAsync(options = {}) {
218
232
  // Fetch org-scoped resources in parallel
219
233
  const orgResults = await Promise.all(
220
234
  orgScopedDefs
221
- .filter((d) => discoveredPluralSet.has(`${d.group || KRATE_API_GROUP}/${d.plural}`))
235
+ .filter((d) => shouldListSnapshotDefinition(d, discoveredPluralSet))
222
236
  .map(async (definition) => {
223
237
  const namespaces = definition.namespaced === false
224
238
  ? [null]
@@ -434,6 +448,12 @@ function namespaceArgs(definition, namespace) {
434
448
  return definition.namespaced === false ? [] : ['-n', namespace];
435
449
  }
436
450
 
451
+ function shouldListSnapshotDefinition(definition, discoveredPluralSet) {
452
+ const group = definition.group || KRATE_API_GROUP;
453
+ if (discoveredPluralSet.has(`${group}/${definition.plural}`)) return true;
454
+ return group === KRATE_API_GROUP;
455
+ }
456
+
437
457
  function parseKubernetesList(stdout) {
438
458
  const parsed = safeJson(stdout);
439
459
  if (!parsed) return { items: [] };
@@ -445,7 +445,7 @@ export async function getControllerSnapshot(options = {}) {
445
445
  const orgScopedDefinitions = snapshotResources.filter((definition) => !definition.platformScoped);
446
446
 
447
447
  for (const definition of platformScopedDefinitions) {
448
- if (!discoveredPluralSet.has(`${definition.group || KRATE_API_GROUP}/${definition.plural}`)) continue;
448
+ if (!shouldListSnapshotDefinition(definition, discoveredPluralSet)) continue;
449
449
  const resourceNamespace = definition.namespace || namespace;
450
450
  const result = runKubectl(['get', apiResourceName(definition), ...namespaceArgs(definition, resourceNamespace), '-o', 'json', '--ignore-not-found'], { kubectl, timeoutMs, env, allowFailure: true });
451
451
  listResults.push({ definition, result });
@@ -454,7 +454,7 @@ export async function getControllerSnapshot(options = {}) {
454
454
 
455
455
  const orgNamespaces = organizationNamespaces(resources.Organization, resources.OrgNamespaceBinding, namespace);
456
456
  for (const definition of orgScopedDefinitions) {
457
- if (!discoveredPluralSet.has(`${definition.group || KRATE_API_GROUP}/${definition.plural}`)) continue;
457
+ if (!shouldListSnapshotDefinition(definition, discoveredPluralSet)) continue;
458
458
  const namespaces = definition.namespaced === false ? [null] : [definition.namespace || null].filter(Boolean).concat(definition.namespace ? [] : orgNamespaces);
459
459
  resources[definition.kind] = namespaces.flatMap((resourceNamespace) => {
460
460
  const effectiveNamespace = resourceNamespace || namespace;
@@ -819,6 +819,12 @@ function organizationNamespaces(organizations = [], bindings = [], fallbackNames
819
819
  return fallbackOrgs.size ? [...fallbackOrgs] : [fallbackNamespace];
820
820
  }
821
821
 
822
+ function shouldListSnapshotDefinition(definition, discoveredPluralSet) {
823
+ const group = definition.group || KRATE_API_GROUP;
824
+ if (discoveredPluralSet.has(`${group}/${definition.plural}`)) return true;
825
+ return group === KRATE_API_GROUP;
826
+ }
827
+
822
828
  function parseKubernetesList(stdout) {
823
829
  const parsed = safeJson(stdout);
824
830
  if (!parsed) return { items: [] };
@@ -0,0 +1,133 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { fetchControllerUiModel, createControllerUiModel, createResource } from '../src/index.js';
4
+
5
+ function degradedRemoteModel() {
6
+ return createControllerUiModel({
7
+ source: 'controller-api',
8
+ namespace: 'krate-staging',
9
+ kubectl: { available: false, context: null, errors: ['fetch failed'] },
10
+ resources: {},
11
+ crds: [],
12
+ events: [],
13
+ permissions: [],
14
+ storage: {},
15
+ commands: []
16
+ }, { organization: 'default' });
17
+ }
18
+
19
+ function liveSnapshot() {
20
+ return {
21
+ source: 'kubernetes',
22
+ namespace: 'krate-staging',
23
+ kubectl: { available: true, context: 'aks-krate-staging', errors: [] },
24
+ apiService: { metadata: { name: 'v1alpha1.krate.a5c.ai' } },
25
+ crds: [{ metadata: { name: 'repositories.krate.a5c.ai' } }],
26
+ resources: {
27
+ Organization: [createResource('Organization', { name: 'default', namespace: 'krate-system' }, { slug: 'default', namespaceName: 'krate-org-default', displayName: 'Default org' })],
28
+ Repository: [createResource('Repository', { name: 'test2', namespace: 'krate-org-default' }, { organizationRef: 'default', visibility: 'internal', defaultBranch: 'main' })]
29
+ },
30
+ events: [],
31
+ permissions: [],
32
+ storage: {},
33
+ commands: []
34
+ };
35
+ }
36
+
37
+ test('fetchControllerUiModel falls back to local snapshot when remote controller returns degraded empty data', async () => {
38
+ const calls = [];
39
+ const model = await fetchControllerUiModel({
40
+ controllerUrl: 'http://krate-api.krate-staging.svc.cluster.local',
41
+ organization: 'default',
42
+ useCache: false,
43
+ fetchImpl: async () => ({ ok: true, json: async () => degradedRemoteModel() }),
44
+ controller: { async snapshot() { calls.push('snapshot'); return liveSnapshot(); } }
45
+ });
46
+
47
+ assert.deepEqual(calls, ['snapshot']);
48
+ assert.equal(model.status, 'ready');
49
+ assert.equal(model.metrics.repositories, 1);
50
+ assert.equal(model.views.dashboard.repositories[0].metadata.name, 'test2');
51
+ assert.ok(model.controller.connection.errors.length > 0);
52
+ });
53
+
54
+ test('fetchControllerUiModel falls back to local snapshot when remote controller fetch throws', async () => {
55
+ const model = await fetchControllerUiModel({
56
+ controllerUrl: 'http://krate-api.krate-staging.svc.cluster.local',
57
+ organization: 'default',
58
+ useCache: false,
59
+ fetchImpl: async () => { throw new Error('connect ECONNREFUSED'); },
60
+ controller: { async snapshot() { return liveSnapshot(); } }
61
+ });
62
+
63
+ assert.equal(model.status, 'ready');
64
+ assert.equal(model.metrics.repositories, 1);
65
+ assert.match(model.controller.connection.errors[0], /ECONNREFUSED/);
66
+ });
67
+ test('fetchControllerUiModel uses bounded async fallback when remote controller hangs', async () => {
68
+ const calls = [];
69
+ const model = await fetchControllerUiModel({
70
+ controllerUrl: 'http://krate-api.krate-staging.svc.cluster.local',
71
+ organization: 'default',
72
+ requestTimeoutMs: 10,
73
+ useCache: false,
74
+ fetchImpl: async (_target, options = {}) => new Promise((_resolve, reject) => {
75
+ const timer = setTimeout(() => reject(new Error('remote controller hung')), 25);
76
+ options.signal?.addEventListener('abort', () => {
77
+ clearTimeout(timer);
78
+ reject(new Error('remote controller hung'));
79
+ }, { once: true });
80
+ }),
81
+ fallbackSnapshot: async () => { calls.push('fallbackSnapshot'); return liveSnapshot(); }
82
+ });
83
+
84
+ assert.deepEqual(calls, ['fallbackSnapshot']);
85
+ assert.equal(model.status, 'ready');
86
+ assert.equal(model.metrics.repositories, 1);
87
+ assert.match(model.controller.connection.errors.join('; '), /remote controller hung/);
88
+ });
89
+
90
+ test('fetchControllerUiModel uses async fallback for degraded empty remote data without constructing sync controller', async () => {
91
+ const calls = [];
92
+ const model = await fetchControllerUiModel({
93
+ controllerUrl: 'http://krate-api.krate-staging.svc.cluster.local',
94
+ organization: 'default',
95
+ useCache: false,
96
+ fetchImpl: async () => ({ ok: true, json: async () => degradedRemoteModel() }),
97
+ fallbackSnapshot: async () => { calls.push('fallbackSnapshot'); return liveSnapshot(); }
98
+ });
99
+
100
+ assert.deepEqual(calls, ['fallbackSnapshot']);
101
+ assert.equal(model.status, 'ready');
102
+ assert.equal(model.views.dashboard.repositories[0].metadata.name, 'test2');
103
+ assert.ok(model.controller.connection.errors.length > 0);
104
+ });
105
+
106
+
107
+ test('fetchControllerUiModel probes local snapshot when remote controller is ready but missing CRD-backed resources', async () => {
108
+ const calls = [];
109
+ const emptyRemote = createControllerUiModel({
110
+ source: 'kubernetes',
111
+ namespace: 'krate-staging',
112
+ kubectl: { available: true, context: 'aks-krate-staging', errors: [] },
113
+ apiService: { metadata: { name: 'v1alpha1.krate.a5c.ai' } },
114
+ resources: {},
115
+ crds: [],
116
+ events: [],
117
+ permissions: [],
118
+ storage: {},
119
+ commands: []
120
+ }, { organization: 'default' });
121
+
122
+ const model = await fetchControllerUiModel({
123
+ controllerUrl: 'http://krate-api.krate-staging.svc.cluster.local',
124
+ organization: 'default',
125
+ useCache: false,
126
+ fetchImpl: async () => ({ ok: true, json: async () => emptyRemote }),
127
+ fallbackSnapshot: async () => { calls.push('fallbackSnapshot'); return liveSnapshot(); }
128
+ });
129
+
130
+ assert.deepEqual(calls, ['fallbackSnapshot']);
131
+ assert.equal(model.metrics.repositories, 1);
132
+ assert.equal(model.views.dashboard.repositories[0].metadata.name, 'test2');
133
+ });
@@ -222,8 +222,10 @@ test('web UI is wired to the Kubernetes controller API instead of a static local
222
222
  assert.ok(client.includes('AbortSignal.timeout'));
223
223
  assert.ok(client.includes('if (!useCache) return revalidateFn();'));
224
224
  assert.ok(client.includes('staleWhileRevalidate(organization, revalidateFn, swrOptions)'));
225
- assert.ok(client.includes('createKrateApiController'));
226
- assert.ok(client.includes('createKubernetesResourceGateway'));
225
+ assert.ok(client.includes('getControllerSnapshotAsync'));
226
+ assert.ok(client.includes('fallbackSnapshot'));
227
+ assert.ok(!client.includes('createKubernetesResourceGateway'));
228
+ assert.ok(!client.includes('createKrateApiController'));
227
229
  assert.ok(apiController.includes('resourceGateway'));
228
230
  assert.ok(apiController.includes('withArchitecture'));
229
231
  assert.ok(apiController.includes('kubernetes-resource-gateway'));
@@ -234,7 +236,7 @@ test('web UI is wired to the Kubernetes controller API instead of a static local
234
236
  assert.ok(gateway.includes('repositoryManifest'));
235
237
  assert.ok(shell.includes('/api/controller'));
236
238
  assert.ok(webControllerRoute.includes('KRATE_CONTROLLER_URL'));
237
- assert.ok(!webControllerRoute.includes('createKrateApiController'), 'web API route proxies the controller service instead of shelling out through local kubectl');
239
+ assert.ok(webControllerRoute.includes('hydrateOrgResourceSummaries'), 'web API route hydrates empty controller summaries from org-scoped resources');
238
240
  assert.ok(shell.includes('ArchitectureMap'));
239
241
  assert.ok(shell.includes('Repository home'));
240
242
  assert.ok(shell.includes('IssueWorkspace'));
@@ -43,7 +43,8 @@ test('chart package contains the MVP Kubernetes install surface', () => {
43
43
 
44
44
  for (const file of requiredChartFiles) assert.equal(existsSync(file), true, `${file} exists`);
45
45
 
46
- const chart = requiredChartFiles.map((file) => readFileSync(file, 'utf8')).join('\n');
46
+ const chart = requiredChartFiles.map((file) => readFileSync(file, 'utf8')).join('\n');
47
+ const values = readFileSync('../charts/values.yaml', 'utf8');
47
48
 
48
49
  for (const kind of ['CustomResourceDefinition', 'Deployment', 'Service', 'ServiceAccount', 'ClusterRole', 'NetworkPolicy', 'PersistentVolumeClaim']) assert.ok(chart.includes(`kind: ${kind}`), `chart includes ${kind}`);
49
50
 
@@ -64,6 +65,8 @@ test('chart package contains the MVP Kubernetes install surface', () => {
64
65
  assert.ok(chart.includes('repositorypermissions.krate.a5c.ai'), 'package includes Gitea permission reconciliation resources');
65
66
  assert.ok(chart.includes('revoked'), 'package access CRDs allow revocation state');
66
67
  assert.ok(chart.includes('KRATE_CONTROLLER_URL'), 'web deployment points at the in-cluster controller API');
68
+ assert.ok(values.includes('port: 80'), 'controller API service exposes an HTTP service port for in-cluster web fetches');
69
+ assert.ok(!values.includes('port: 443'), 'controller API does not expose plain HTTP through a TLS-looking service port');
67
70
  assert.ok(chart.includes('containerPort: 2222'), 'rootless Gitea SSH service targets port 2222');
68
71
  assert.ok(chart.includes('krate.fullname') && chart.includes('gitea-http'), 'Gitea and Argo CD URLs derive the release-scoped service name');
69
72
  assert.ok(chart.includes('krate.a5c.ai/gitops-engine: argocd'), 'package labels Argo CD GitOps engine');
@@ -329,6 +329,29 @@ test('Delegated identity route redirects localhost fallback even when Kubernetes
329
329
  }
330
330
  });
331
331
 
332
+
333
+
334
+ test('controller UI model keeps namespace-scoped resources visible for their org', () => {
335
+ const model = createControllerUiModel({
336
+ source: 'kubernetes',
337
+ namespace: 'krate-system',
338
+ generatedAt: 'test-time',
339
+ kubectl: { available: true, context: 'kind-krate', errors: [] },
340
+ crds: [],
341
+ resources: {
342
+ Organization: [{ apiVersion: 'krate.a5c.ai/v1alpha1', kind: 'Organization', metadata: { name: 'default', namespace: 'krate-system' }, spec: { slug: 'default', namespaceName: 'krate-org-default' } }],
343
+ RunnerPool: [{ apiVersion: 'krate.a5c.ai/v1alpha1', kind: 'RunnerPool', metadata: { name: 'default', namespace: 'krate-org-default' }, spec: { image: 'ubuntu:24.04' } }]
344
+ },
345
+ events: [],
346
+ permissions: [],
347
+ storage: {},
348
+ commands: []
349
+ }, { organization: 'default' });
350
+
351
+ assert.equal(model.metrics.runnerPools, 1);
352
+ assert.deepEqual(model.resources.find((resource) => resource.kind === 'RunnerPool').names, ['default']);
353
+ });
354
+
332
355
  test('Krate delivery resources surface through controller UI model', () => {
333
356
  const model = createControllerUiModel({
334
357
  source: 'kubernetes',
@@ -3,6 +3,9 @@
3
3
  */
4
4
 
5
5
  import assert from 'node:assert/strict';
6
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import path from 'node:path';
6
9
  import test from 'node:test';
7
10
  import { runKubectlAsync, getPartialSnapshot, getControllerSnapshotAsync } from '../src/kubernetes-controller-async.js';
8
11
  import {
@@ -83,6 +86,71 @@ test('runKubectlAsync rejects on timeout when allowFailure is not set', async ()
83
86
  );
84
87
  });
85
88
 
89
+
90
+ test('getControllerSnapshotAsync uses in-cluster service account instead of kubeconfig current-context', async () => {
91
+ const tempDir = await mkdtemp(path.join(tmpdir(), 'krate-sa-'));
92
+ const kubectlPath = path.join(tempDir, process.platform === 'win32' ? 'kubectl.cmd' : 'kubectl');
93
+ const kubectlScript = process.platform === 'win32'
94
+ ? '@echo off\r\nnode -e "const args=process.argv.slice(1); if(args.includes(\'version\')){console.log(JSON.stringify({clientVersion:{gitVersion:\'v1.32.2\'}})); process.exit(0)} if(args.includes(\'current-context\')){console.error(\'current-context should not be called\'); process.exit(9)} console.log(JSON.stringify({items:[]}));" -- %*\r\n'
95
+ : '#!/usr/bin/env sh\nnode -e "const args=process.argv.slice(1); if(args.includes(\'version\')){console.log(JSON.stringify({clientVersion:{gitVersion:\'v1.32.2\'}})); process.exit(0)} if(args.includes(\'current-context\')){console.error(\'current-context should not be called\'); process.exit(9)} console.log(JSON.stringify({items:[]}));" -- "$@"\n';
96
+
97
+ try {
98
+ await writeFile(path.join(tempDir, 'token'), 'token');
99
+ await writeFile(path.join(tempDir, 'ca.crt'), 'ca');
100
+ await writeFile(kubectlPath, kubectlScript, { mode: 0o755 });
101
+
102
+ const snapshot = await getControllerSnapshotAsync({
103
+ kubectl: kubectlPath,
104
+ timeoutMs: 500,
105
+ env: {
106
+ KUBERNETES_SERVICE_HOST: '10.0.0.1',
107
+ KUBERNETES_SERVICE_PORT: '443',
108
+ KRATE_SERVICE_ACCOUNT_DIR: tempDir
109
+ }
110
+ });
111
+
112
+ assert.equal(snapshot.kubectl.context, 'in-cluster');
113
+ if (process.platform !== 'win32') assert.equal(snapshot.kubectl.available, true);
114
+ assert.doesNotMatch((snapshot.kubectl.errors || []).join('; '), /current-context/);
115
+ } finally {
116
+ await rm(tempDir, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+
121
+ test('full async snapshot lists known Krate CRDs even when CRD discovery is empty', async () => {
122
+ const tempDir = await mkdtemp(path.join(tmpdir(), 'krate-known-crds-'));
123
+ const fakePath = path.join(tempDir, 'fake-kubectl.cjs');
124
+ const fakeScript = `
125
+ const args = process.argv.slice(1);
126
+ const joined = args.join(' ');
127
+ const has = (value) => args.includes(value) || joined.includes(value);
128
+ if (has('current-context')) { console.log('kind-krate'); process.exit(0); }
129
+ if (has('version')) { console.log(JSON.stringify({ clientVersion: { gitVersion: 'v1.test' } })); process.exit(0); }
130
+ if (has('apiservice')) { console.log(JSON.stringify({ metadata: { name: 'v1alpha1.krate.a5c.ai' } })); process.exit(0); }
131
+ if (has('crd')) { console.error('crd discovery unavailable'); process.exit(1); }
132
+ if (has('runnerpools.krate.a5c.ai')) {
133
+ console.log(JSON.stringify({ items: [{ apiVersion: 'krate.a5c.ai/v1alpha1', kind: 'RunnerPool', metadata: { name: 'default', namespace: 'krate-org-default', labels: { 'krate.a5c.ai/org': 'default' } }, spec: { organizationRef: 'default', image: 'ubuntu:24.04' } }] }));
134
+ process.exit(0);
135
+ }
136
+ console.log(JSON.stringify({ items: [] }));
137
+ process.exit(0);
138
+ `;
139
+
140
+ try {
141
+ await writeFile(fakePath, fakeScript);
142
+ const snapshot = await getControllerSnapshotAsync({
143
+ kubectl: process.execPath,
144
+ timeoutMs: 1000,
145
+ env: { KRATE_ORG: 'default', NODE_OPTIONS: `--require ${fakePath}` }
146
+ });
147
+ assert.equal(snapshot.resources.RunnerPool.length, 1);
148
+ assert.equal(snapshot.resources.RunnerPool[0].metadata.name, 'default');
149
+ } finally {
150
+ await rm(tempDir, { recursive: true, force: true });
151
+ }
152
+ });
153
+
86
154
  // ---------------------------------------------------------------------------
87
155
  // getPartialSnapshot
88
156
  // ---------------------------------------------------------------------------