@nextclaw/ui 0.13.8-beta.0 → 0.13.9

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 (85) hide show
  1. package/CHANGELOG.md +22 -8
  2. package/dist/assets/{api-BCDb13Uv.js → api-CIJxdHmy.js} +5 -5
  3. package/dist/assets/app-presenter-provider-CKZqZfv5.js +3 -0
  4. package/dist/assets/arrow-left-DHcD-tbC.js +1 -0
  5. package/dist/assets/channels-list-page-C_Xe0CNX.js +8 -0
  6. package/dist/assets/chat-page-CXRtoQR2.js +105 -0
  7. package/dist/assets/{config-split-page-BwhGqmUS.js → config-split-page-DBAcw9mJ.js} +1 -1
  8. package/dist/assets/{confirm-dialog-DBkKcQ1q.js → confirm-dialog-DwPTxq8C.js} +1 -1
  9. package/dist/assets/{desktop-update-config-BE9KjiXi.js → desktop-update-config-BqABa_1C.js} +1 -1
  10. package/dist/assets/{dist-BI0bX8jv.js → dist-BfcibKyv.js} +1 -1
  11. package/dist/assets/{dist-C8JRt-tf.js → dist-WjhKB-pc.js} +1 -1
  12. package/dist/assets/{doc-browser-eqdNEerh.js → doc-browser-8EEDwGCJ.js} +1 -1
  13. package/dist/assets/doc-browser-DlT90zQD.js +1 -0
  14. package/dist/assets/{doc-browser-context-VHUOIUXL.js → doc-browser-context-DLJOy6Am.js} +1 -1
  15. package/dist/assets/{ellipsis-C9VB2CxT.js → ellipsis-H-4aNwgt.js} +1 -1
  16. package/dist/assets/{es2015-BCw3Q7nY.js → es2015-B5k1IpKX.js} +1 -1
  17. package/dist/assets/{external-link-Bp7_4s9t.js → external-link-BN1wd-eI.js} +1 -1
  18. package/dist/assets/{index-DTdPDqto.js → index-WJt0X9wC.js} +5 -5
  19. package/dist/assets/{key-round-Ccy6oND3.js → key-round-D-7IaAQS.js} +1 -1
  20. package/dist/assets/{loader-circle-OpH4EErP.js → loader-circle-CB38Q7Ws.js} +1 -1
  21. package/dist/assets/mcp-marketplace-page-CmZwt64F.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-B-GxKOqS.js → mcp-marketplace-page-ju1KH3TG.js} +2 -2
  23. package/dist/assets/{model-config-BXPnV_7n.js → model-config-CgS6dp65.js} +1 -1
  24. package/dist/assets/{notice-card-Dsbkl7u7.js → notice-card-DW9BLKpG.js} +1 -1
  25. package/dist/assets/play-DGpH8jeI.js +1 -0
  26. package/dist/assets/plus-CkcQjwuH.js +1 -0
  27. package/dist/assets/{popover-BTLZE5Iz.js → popover-ofN845gZ.js} +1 -1
  28. package/dist/assets/{provider-scoped-model-input-DKI9KN7N.js → provider-scoped-model-input-DaDqi8Zy.js} +1 -1
  29. package/dist/assets/providers-list-CZNQXY4n.js +1 -0
  30. package/dist/assets/{react-BqS8oMyM.js → react-Bq34NL59.js} +1 -1
  31. package/dist/assets/{refresh-cw-BIaj7txb.js → refresh-cw-DJttSXS8.js} +1 -1
  32. package/dist/assets/remote-CYmsM9DD.js +1 -0
  33. package/dist/assets/rotate-cw-D_xslHna.js +1 -0
  34. package/dist/assets/{runtime-config-page-DX_wSK1J.js → runtime-config-page-gUjM6Z_A.js} +1 -1
  35. package/dist/assets/{save-BEoORten.js → save-DtAFbS7x.js} +1 -1
  36. package/dist/assets/search-CNMRwAFx.js +1 -0
  37. package/dist/assets/{search-config-DA5By-tE.js → search-config-C04PrmHj.js} +1 -1
  38. package/dist/assets/{secrets-config-eDxG7iN4.js → secrets-config-DvxHdz9h.js} +2 -2
  39. package/dist/assets/{status-dot-oWBjTovo.js → status-dot-D4jbKDLB.js} +1 -1
  40. package/dist/assets/{tabs-CGaY0BVO.js → tabs-Ca2vw7YQ.js} +1 -1
  41. package/dist/assets/{tabs-custom-D2fPYb4G.js → tabs-custom-DzGDjYxf.js} +1 -1
  42. package/dist/assets/{tag-chip-TVkgofS1.js → tag-chip-CW-_pHjH.js} +1 -1
  43. package/dist/assets/{tooltip-BwHPKBv9.js → tooltip-Cq2mERPk.js} +1 -1
  44. package/dist/assets/{trash-2-D3psAGrz.js → trash-2-rumaEu9l.js} +1 -1
  45. package/dist/assets/x-DGqjdMUt.js +1 -0
  46. package/dist/index.html +24 -24
  47. package/package.json +8 -8
  48. package/src/app/components/layout/runtime-status-entry.test.tsx +5 -0
  49. package/src/app/components/layout/runtime-status-entry.tsx +8 -0
  50. package/src/features/chat/components/conversation/chat-message-list.container.tsx +0 -6
  51. package/src/features/chat/components/workspace/session-cron-job-content.tsx +3 -3
  52. package/src/features/chat/hooks/use-chat-session-update.test.tsx +2 -1
  53. package/src/features/chat/managers/chat-session-preference-sync.manager.test.ts +2 -1
  54. package/src/features/chat/utils/chat-input-bar.utils.ts +0 -2
  55. package/src/features/chat/utils/chat-session-preference-governance.utils.ts +4 -1
  56. package/src/features/marketplace/components/mcp/mcp-marketplace-page.test.tsx +2 -1
  57. package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +125 -119
  58. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +47 -17
  59. package/src/features/service-apps/components/service-action-authorization-dialog.tsx +38 -5
  60. package/src/features/service-apps/stores/service-action-authorization.store.ts +7 -3
  61. package/src/features/system-status/components/desktop-update-config.test.tsx +2 -1
  62. package/src/features/system-status/hooks/use-system-status.ts +1 -4
  63. package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +137 -100
  64. package/src/features/system-status/managers/system-status.manager.ts +38 -20
  65. package/src/shared/components/cron-config.tsx +1 -5
  66. package/src/shared/lib/api/types.ts +0 -4
  67. package/src/shared/lib/cadence/index.ts +7 -0
  68. package/src/shared/lib/cadence/services/adaptive-cadence.service.test.ts +95 -0
  69. package/src/shared/lib/cadence/services/adaptive-cadence.service.ts +120 -0
  70. package/src/shared/lib/cron/cron-job-view.utils.ts +0 -9
  71. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +4 -3
  72. package/src/shared/lib/i18n/runtime/i18n-language-owner.test.ts +3 -2
  73. package/dist/assets/app-presenter-provider-C-QMjK7m.js +0 -3
  74. package/dist/assets/arrow-left-rIXFA2Zy.js +0 -1
  75. package/dist/assets/channels-list-page-DnqwsKGe.js +0 -8
  76. package/dist/assets/chat-page-CsQx20xL.js +0 -105
  77. package/dist/assets/doc-browser-lpF_pyx4.js +0 -1
  78. package/dist/assets/mcp-marketplace-page-Dm-0-w51.js +0 -1
  79. package/dist/assets/play-CvgYhNbo.js +0 -1
  80. package/dist/assets/plus-DHcP-K1U.js +0 -1
  81. package/dist/assets/providers-list-Bko6B7dE.js +0 -1
  82. package/dist/assets/remote-DHn_b8QU.js +0 -1
  83. package/dist/assets/rotate-cw-DMqSkRJQ.js +0 -1
  84. package/dist/assets/search-COFh_SFI.js +0 -1
  85. package/dist/assets/x-CHAxzyp2.js +0 -1
@@ -1,17 +1,77 @@
1
- import { beforeEach, describe, expect, it } from 'vitest';
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { systemStatusManager } from './system-status.manager';
3
- import { useSystemStatusStore } from '@/features/system-status/stores/system-status.store';
3
+ import { appQueryClient } from '@/app-query-client';
4
+ import type { BootstrapStatusView } from '@/shared/lib/api';
5
+
6
+ const initializingBootstrapStatus: BootstrapStatusView = {
7
+ phase: 'shell-ready',
8
+ ncpAgent: {
9
+ state: 'running',
10
+ },
11
+ extensionLoading: {
12
+ state: 'pending',
13
+ loadedExtensionCount: 0,
14
+ totalExtensionCount: 0,
15
+ },
16
+ channels: {
17
+ state: 'pending',
18
+ enabled: [],
19
+ },
20
+ remote: {
21
+ state: 'pending',
22
+ },
23
+ };
24
+
25
+ const errorBootstrapStatus: BootstrapStatusView = {
26
+ phase: 'error',
27
+ ncpAgent: {
28
+ state: 'error',
29
+ error: 'startup failed',
30
+ },
31
+ extensionLoading: {
32
+ state: 'pending',
33
+ loadedExtensionCount: 0,
34
+ totalExtensionCount: 0,
35
+ },
36
+ channels: {
37
+ state: 'pending',
38
+ enabled: [],
39
+ },
40
+ remote: {
41
+ state: 'pending',
42
+ },
43
+ lastError: 'startup failed',
44
+ };
45
+
46
+ const readyBootstrapStatus: BootstrapStatusView = {
47
+ phase: 'ready',
48
+ ncpAgent: {
49
+ state: 'ready',
50
+ },
51
+ extensionLoading: {
52
+ state: 'ready',
53
+ loadedExtensionCount: 1,
54
+ totalExtensionCount: 1,
55
+ },
56
+ channels: {
57
+ state: 'ready',
58
+ enabled: [],
59
+ },
60
+ remote: {
61
+ state: 'disabled',
62
+ },
63
+ };
4
64
 
5
65
  describe('getRuntimeBootstrapPollInterval', () => {
6
66
  beforeEach(() => {
7
- useSystemStatusStore.setState({
8
- state: {
9
- ...useSystemStatusStore.getState().state,
10
- lifecyclePhase: 'cold-starting',
11
- hasReachedReady: false,
12
- activeSystemAction: null,
13
- },
14
- });
67
+ vi.useFakeTimers();
68
+ systemStatusManager.resetForTests();
69
+ });
70
+
71
+ afterEach(() => {
72
+ systemStatusManager.resetForTests();
73
+ vi.restoreAllMocks();
74
+ vi.useRealTimers();
15
75
  });
16
76
 
17
77
  it('keeps polling while bootstrap status is missing', () => {
@@ -22,115 +82,92 @@ describe('getRuntimeBootstrapPollInterval', () => {
22
82
 
23
83
  it('keeps polling while ncp agent is still initializing', () => {
24
84
  expect(
25
- systemStatusManager.getRuntimeBootstrapPollInterval({
26
- phase: 'shell-ready',
27
- ncpAgent: {
28
- state: 'running',
29
- },
30
- extensionLoading: {
31
- state: 'pending',
32
- loadedExtensionCount: 0,
33
- totalExtensionCount: 0,
34
- },
35
- channels: {
36
- state: 'pending',
37
- enabled: [],
38
- },
39
- remote: {
40
- state: 'pending',
41
- },
42
- })
85
+ systemStatusManager.getRuntimeBootstrapPollInterval(
86
+ initializingBootstrapStatus
87
+ )
43
88
  ).toBe(1000);
44
89
  });
45
90
 
46
- it('continues polling even when bootstrap status reports an ncp agent error', () => {
91
+ it('starts hot backoff when bootstrap status reports an ncp agent error', () => {
92
+ systemStatusManager.reportBootstrapStatus(errorBootstrapStatus);
93
+
47
94
  expect(
48
- systemStatusManager.getRuntimeBootstrapPollInterval({
49
- phase: 'error',
50
- ncpAgent: {
51
- state: 'error',
52
- error: 'startup failed',
53
- },
54
- extensionLoading: {
55
- state: 'pending',
56
- loadedExtensionCount: 0,
57
- totalExtensionCount: 0,
58
- },
59
- channels: {
60
- state: 'pending',
61
- enabled: [],
62
- },
63
- remote: {
64
- state: 'pending',
65
- },
66
- lastError: 'startup failed',
67
- })
68
- ).toBe(2000);
95
+ systemStatusManager.getRuntimeBootstrapPollInterval(errorBootstrapStatus)
96
+ ).toBe(500);
69
97
  });
70
98
 
71
99
  it('stops polling once the ncp agent is ready', () => {
72
100
  expect(
73
- systemStatusManager.getRuntimeBootstrapPollInterval({
74
- phase: 'ready',
75
- ncpAgent: {
76
- state: 'ready',
77
- },
78
- extensionLoading: {
79
- state: 'ready',
80
- loadedExtensionCount: 1,
81
- totalExtensionCount: 1,
82
- },
83
- channels: {
84
- state: 'ready',
85
- enabled: [],
86
- },
87
- remote: {
88
- state: 'disabled',
89
- },
90
- })
101
+ systemStatusManager.getRuntimeBootstrapPollInterval(readyBootstrapStatus)
91
102
  ).toBe(false);
92
103
  });
93
104
 
94
105
  it('keeps polling while the runtime is recovering even if the last bootstrap status was ready', () => {
95
- useSystemStatusStore.setState({
96
- state: {
97
- ...useSystemStatusStore.getState().state,
98
- lifecyclePhase: 'recovering',
99
- hasReachedReady: true,
100
- lastReadyAt: Date.now(),
101
- recoveryStartedAt: Date.now(),
102
- },
103
- });
106
+ systemStatusManager.reportBootstrapStatus(readyBootstrapStatus);
107
+ systemStatusManager.handleConnectionInterrupted('websocket error');
104
108
 
105
109
  expect(
106
- systemStatusManager.getRuntimeBootstrapPollInterval({
107
- phase: 'ready',
108
- ncpAgent: {
109
- state: 'ready',
110
- },
111
- extensionLoading: {
112
- state: 'ready',
113
- loadedExtensionCount: 1,
114
- totalExtensionCount: 1,
115
- },
116
- channels: {
117
- state: 'ready',
118
- enabled: [],
119
- },
120
- remote: {
121
- state: 'disabled',
122
- },
123
- })
124
- ).toBe(1000);
110
+ systemStatusManager.getRuntimeBootstrapPollInterval(readyBootstrapStatus)
111
+ ).toBe(500);
125
112
  });
126
113
 
127
114
  it('backs off polling after transport failures', () => {
115
+ systemStatusManager.reportBootstrapQueryError(new Error('Failed to fetch'));
116
+ expect(systemStatusManager.getRuntimeBootstrapPollInterval(undefined)).toBe(
117
+ 500
118
+ );
119
+
120
+ systemStatusManager.reportBootstrapQueryError(new Error('Failed to fetch'));
128
121
  expect(
129
- systemStatusManager.getRuntimeBootstrapPollInterval(undefined, 1)
130
- ).toBe(2000);
122
+ systemStatusManager.getRuntimeBootstrapPollInterval(undefined)
123
+ ).toBe(1000);
124
+
125
+ systemStatusManager.reportBootstrapQueryError(new Error('Failed to fetch'));
131
126
  expect(
132
- systemStatusManager.getRuntimeBootstrapPollInterval(undefined, 3)
133
- ).toBe(5000);
127
+ systemStatusManager.getRuntimeBootstrapPollInterval(undefined)
128
+ ).toBe(2000);
129
+ });
130
+
131
+ it('slows down after the hot recovery and long failure windows', async () => {
132
+ systemStatusManager.reportBootstrapQueryError(new Error('Failed to fetch'));
133
+
134
+ await vi.advanceTimersByTimeAsync(31_000);
135
+ expect(systemStatusManager.getRuntimeBootstrapPollInterval(undefined)).toBe(
136
+ 10_000
137
+ );
138
+
139
+ systemStatusManager.reportBootstrapQueryError(new Error('Failed to fetch'));
140
+ systemStatusManager.reportBootstrapQueryError(new Error('Failed to fetch'));
141
+ expect(systemStatusManager.getRuntimeBootstrapPollInterval(undefined)).toBe(
142
+ 30_000
143
+ );
144
+
145
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
146
+ expect(systemStatusManager.getRuntimeBootstrapPollInterval(undefined)).toBe(
147
+ 60_000
148
+ );
134
149
  });
135
150
 
151
+ it('lets manual runtime probe trigger one immediate interval after readiness stopped polling', async () => {
152
+ const refetchQueriesSpy = vi
153
+ .spyOn(appQueryClient, 'refetchQueries')
154
+ .mockResolvedValue(undefined as never);
155
+
156
+ systemStatusManager.reportBootstrapStatus(readyBootstrapStatus);
157
+ expect(
158
+ systemStatusManager.getRuntimeBootstrapPollInterval(readyBootstrapStatus)
159
+ ).toBe(false);
160
+
161
+ systemStatusManager.requestRuntimeBootstrapProbeNow();
162
+
163
+ expect(refetchQueriesSpy).toHaveBeenCalledWith({
164
+ queryKey: ['runtime-bootstrap-status'],
165
+ });
166
+ expect(
167
+ systemStatusManager.getRuntimeBootstrapPollInterval(readyBootstrapStatus)
168
+ ).toBe(0);
169
+ expect(
170
+ systemStatusManager.getRuntimeBootstrapPollInterval(readyBootstrapStatus)
171
+ ).toBe(false);
172
+ });
136
173
  });
@@ -22,12 +22,18 @@ import {
22
22
  useSystemStatusStore,
23
23
  } from '@/features/system-status/stores/system-status.store';
24
24
  import { isTransientRuntimeConnectionErrorMessage } from '@/shared/lib/transport';
25
+ import { AdaptiveCadence } from '@/shared/lib/cadence';
25
26
 
26
27
  const RECOVERY_TIMEOUT_MS = 30_000;
27
- const RUNTIME_BOOTSTRAP_PROBE_POLICY = {
28
- activePollIntervalMs: 1_000,
29
- errorPollIntervalMs: 2_000,
30
- maxErrorPollIntervalMs: 5_000,
28
+ const RUNTIME_BOOTSTRAP_PROBE_CADENCE = {
29
+ idleDelayMs: 1_000,
30
+ manualTriggerDelayMs: 0,
31
+ successDelayMs: false,
32
+ stages: [
33
+ { untilElapsedMs: 30_000, delaysMs: [500, 1_000, 2_000, 4_000, 5_000] },
34
+ { untilElapsedMs: 5 * 60_000, delaysMs: [10_000, 15_000, 30_000] },
35
+ { delaysMs: [60_000] },
36
+ ],
31
37
  } as const;
32
38
 
33
39
  function getErrorMessage(error: unknown): string {
@@ -61,32 +67,29 @@ function resolveActionHelp(action: RuntimeControlAction): string {
61
67
 
62
68
  export class SystemStatusManager {
63
69
  private recoveryTimeoutId: number | null = null;
70
+ private readonly runtimeBootstrapProbeCadence = new AdaptiveCadence(
71
+ RUNTIME_BOOTSTRAP_PROBE_CADENCE,
72
+ );
64
73
 
65
74
  getRuntimeBootstrapPollInterval = (
66
75
  status: BootstrapStatusView | null | undefined,
67
- fetchFailureCount = 0
68
76
  ): number | false => {
69
77
  const { lifecyclePhase, activeSystemAction } = this.getState();
70
- if (fetchFailureCount > 0) {
71
- return Math.min(
72
- RUNTIME_BOOTSTRAP_PROBE_POLICY.maxErrorPollIntervalMs,
73
- RUNTIME_BOOTSTRAP_PROBE_POLICY.errorPollIntervalMs * fetchFailureCount
74
- );
75
- }
76
- if (
78
+ const isRecovering =
77
79
  lifecyclePhase === 'recovering' ||
78
80
  lifecyclePhase === 'stalled' ||
79
- activeSystemAction?.lifecycle === 'recovering'
80
- ) {
81
- return RUNTIME_BOOTSTRAP_PROBE_POLICY.activePollIntervalMs;
81
+ activeSystemAction?.lifecycle === 'recovering';
82
+ if (this.runtimeBootstrapProbeCadence.hasManualTrigger()) {
83
+ return this.runtimeBootstrapProbeCadence.getNextDelay({
84
+ consumeManualTrigger: true,
85
+ });
82
86
  }
83
- if (status?.ncpAgent.state === 'ready') {
87
+ if (!isRecovering && status?.ncpAgent.state === 'ready') {
84
88
  return false;
85
89
  }
86
- if (status?.ncpAgent.state === 'error' || status?.phase === 'error') {
87
- return RUNTIME_BOOTSTRAP_PROBE_POLICY.errorPollIntervalMs;
88
- }
89
- return RUNTIME_BOOTSTRAP_PROBE_POLICY.activePollIntervalMs;
90
+ return this.runtimeBootstrapProbeCadence.getNextDelay({
91
+ consumeManualTrigger: true,
92
+ });
90
93
  };
91
94
 
92
95
  getRuntimeControl = async (): Promise<RuntimeControlView> => {
@@ -137,6 +140,7 @@ export class SystemStatusManager {
137
140
  }
138
141
  const state = this.getState();
139
142
  if (!state.hasReachedReady) {
143
+ this.runtimeBootstrapProbeCadence.recordFailure();
140
144
  this.patchState({
141
145
  lastTransportError: message,
142
146
  });
@@ -150,6 +154,7 @@ export class SystemStatusManager {
150
154
  const state = this.getState();
151
155
  const normalizedMessage = message?.trim() || null;
152
156
  if (!state.hasReachedReady) {
157
+ this.runtimeBootstrapProbeCadence.recordFailure();
153
158
  if (normalizedMessage) {
154
159
  this.patchState({
155
160
  lastTransportError: normalizedMessage,
@@ -215,6 +220,7 @@ export class SystemStatusManager {
215
220
  message: t('runtimeControlRecoveringHelp'),
216
221
  },
217
222
  });
223
+ this.runtimeBootstrapProbeCadence.requestManualTrigger();
218
224
 
219
225
  const recoveredView = await this.waitForRecovery();
220
226
  this.syncRuntimeControlQueryCache(recoveredView);
@@ -240,8 +246,16 @@ export class SystemStatusManager {
240
246
 
241
247
  getStatusView = () => toSystemStatusView(this.getState());
242
248
 
249
+ requestRuntimeBootstrapProbeNow = (): void => {
250
+ this.runtimeBootstrapProbeCadence.requestManualTrigger();
251
+ void appQueryClient.refetchQueries({
252
+ queryKey: ['runtime-bootstrap-status'],
253
+ });
254
+ };
255
+
243
256
  resetForTests = (): void => {
244
257
  this.clearRecoveryTimeout();
258
+ this.runtimeBootstrapProbeCadence.reset();
245
259
  useSystemStatusStore.setState({
246
260
  state: initialSystemStatusState,
247
261
  });
@@ -258,6 +272,7 @@ export class SystemStatusManager {
258
272
  private transitionToColdStarting = (
259
273
  bootstrapStatus: BootstrapStatusView
260
274
  ): void => {
275
+ this.runtimeBootstrapProbeCadence.recordFailure();
261
276
  this.clearRecoveryTimeout();
262
277
  this.patchState({
263
278
  lifecyclePhase: 'cold-starting',
@@ -271,6 +286,7 @@ export class SystemStatusManager {
271
286
  errorMessage: string | null,
272
287
  bootstrapStatus?: BootstrapStatusView
273
288
  ): void => {
289
+ this.runtimeBootstrapProbeCadence.recordFailure();
274
290
  this.clearRecoveryTimeout();
275
291
  this.patchState({
276
292
  lifecyclePhase: 'startup-failed',
@@ -284,6 +300,7 @@ export class SystemStatusManager {
284
300
  errorMessage: string | null,
285
301
  bootstrapStatus?: BootstrapStatusView
286
302
  ): void => {
303
+ this.runtimeBootstrapProbeCadence.recordFailure();
287
304
  const state = this.getState();
288
305
  if (!state.hasReachedReady) {
289
306
  this.patchState({
@@ -321,6 +338,7 @@ export class SystemStatusManager {
321
338
  private transitionToReady = (
322
339
  bootstrapStatus: BootstrapStatusView | null
323
340
  ): void => {
341
+ this.runtimeBootstrapProbeCadence.recordSuccess();
324
342
  const state = this.getState();
325
343
  const shouldRefreshQueries =
326
344
  state.lifecyclePhase === 'recovering' || state.lifecyclePhase === 'stalled';
@@ -7,7 +7,6 @@ import { Input } from '@/shared/components/ui/input';
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
8
8
  import { Card, CardContent } from '@/shared/components/ui/card';
9
9
  import {
10
- describeCronDelivery,
11
10
  describeCronSchedule,
12
11
  describeCronSession,
13
12
  formatCronDate,
@@ -26,9 +25,7 @@ function matchQuery(job: CronJobView, query: string): boolean {
26
25
  job.id,
27
26
  job.name,
28
27
  job.payload.message,
29
- job.payload.sessionId ?? '',
30
- job.payload.channel ?? '',
31
- job.payload.to ?? ''
28
+ job.payload.sessionId ?? ''
32
29
  ].join(' ').toLowerCase();
33
30
  return haystack.includes(q);
34
31
  }
@@ -64,7 +61,6 @@ function CronJobCard(props: {
64
61
  <div className="mt-2 text-xs text-gray-500">{t('cronScheduleLabel')}: {describeCronSchedule(job)}</div>
65
62
  <div className="mt-2 whitespace-pre-wrap break-words text-sm text-gray-700">{job.payload.message}</div>
66
63
  <div className="mt-2 text-xs text-gray-500">{t('cronSessionLabel')}: {describeCronSession(job)}</div>
67
- <div className="mt-2 text-xs text-gray-500">{t('cronDeliverTo')}: {describeCronDelivery(job)}</div>
68
64
  </div>
69
65
  <div className="min-w-[220px] space-y-2 text-xs text-gray-500">
70
66
  <div><span className="font-medium text-gray-700">{t('cronNextRun')}:</span> {formatCronDate(job.state.nextRunAt)}</div>
@@ -402,10 +402,6 @@ export type CronPayloadView = {
402
402
  message: string;
403
403
  agentId?: string | null;
404
404
  sessionId?: string | null;
405
- deliver?: boolean;
406
- channel?: string | null;
407
- to?: string | null;
408
- accountId?: string | null;
409
405
  };
410
406
 
411
407
  export type CronJobStateView = {
@@ -0,0 +1,7 @@
1
+ export {
2
+ AdaptiveCadence,
3
+ type AdaptiveCadenceDelayOptions,
4
+ type AdaptiveCadenceOptions,
5
+ type AdaptiveCadenceSnapshot,
6
+ type AdaptiveCadenceStage,
7
+ } from './services/adaptive-cadence.service';
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AdaptiveCadence } from './adaptive-cadence.service';
3
+
4
+ function createClock(initialNow = 0) {
5
+ let now = initialNow;
6
+ return {
7
+ clock: () => now,
8
+ advance: (ms: number) => {
9
+ now += ms;
10
+ },
11
+ };
12
+ }
13
+
14
+ function createCadence(clock: () => number): AdaptiveCadence {
15
+ return new AdaptiveCadence({
16
+ idleDelayMs: 1000,
17
+ manualTriggerDelayMs: 0,
18
+ successDelayMs: false,
19
+ stages: [
20
+ { untilElapsedMs: 30_000, delaysMs: [500, 1000, 2000, 4000, 5000] },
21
+ { untilElapsedMs: 5 * 60_000, delaysMs: [10_000, 15_000, 30_000] },
22
+ { delaysMs: [60_000] },
23
+ ],
24
+ clock,
25
+ });
26
+ }
27
+
28
+ describe('AdaptiveCadence', () => {
29
+ it('uses idle delay before the first failure', () => {
30
+ const { clock } = createClock();
31
+ const cadence = createCadence(clock);
32
+
33
+ expect(cadence.getNextDelay()).toBe(1000);
34
+ });
35
+
36
+ it('backs off inside the hot recovery stage', () => {
37
+ const { clock } = createClock();
38
+ const cadence = createCadence(clock);
39
+
40
+ cadence.recordFailure();
41
+ expect(cadence.getNextDelay()).toBe(500);
42
+ cadence.recordFailure();
43
+ expect(cadence.getNextDelay()).toBe(1000);
44
+ cadence.recordFailure();
45
+ expect(cadence.getNextDelay()).toBe(2000);
46
+ cadence.recordFailure();
47
+ expect(cadence.getNextDelay()).toBe(4000);
48
+ cadence.recordFailure();
49
+ expect(cadence.getNextDelay()).toBe(5000);
50
+ cadence.recordFailure();
51
+ expect(cadence.getNextDelay()).toBe(5000);
52
+ });
53
+
54
+ it('moves to slower stages as the same failure window ages', () => {
55
+ const { advance, clock } = createClock();
56
+ const cadence = createCadence(clock);
57
+
58
+ cadence.recordFailure();
59
+ advance(31_000);
60
+ expect(cadence.getNextDelay()).toBe(10_000);
61
+
62
+ cadence.recordFailure();
63
+ cadence.recordFailure();
64
+ expect(cadence.getNextDelay()).toBe(30_000);
65
+
66
+ advance(5 * 60_000);
67
+ expect(cadence.getNextDelay()).toBe(60_000);
68
+ });
69
+
70
+ it('stops after success and starts a fresh window after the next failure', () => {
71
+ const { clock } = createClock();
72
+ const cadence = createCadence(clock);
73
+
74
+ cadence.recordFailure();
75
+ cadence.recordFailure();
76
+ expect(cadence.getNextDelay()).toBe(1000);
77
+
78
+ cadence.recordSuccess();
79
+ expect(cadence.getNextDelay()).toBe(false);
80
+
81
+ cadence.recordFailure();
82
+ expect(cadence.getNextDelay()).toBe(500);
83
+ });
84
+
85
+ it('supports a one-shot manual trigger delay', () => {
86
+ const { clock } = createClock();
87
+ const cadence = createCadence(clock);
88
+
89
+ cadence.recordSuccess();
90
+ cadence.requestManualTrigger();
91
+
92
+ expect(cadence.getNextDelay({ consumeManualTrigger: true })).toBe(0);
93
+ expect(cadence.getNextDelay()).toBe(1000);
94
+ });
95
+ });
@@ -0,0 +1,120 @@
1
+ export type AdaptiveCadenceStage = {
2
+ untilElapsedMs?: number;
3
+ delaysMs: readonly number[];
4
+ };
5
+
6
+ export type AdaptiveCadenceOptions = {
7
+ idleDelayMs: number | false;
8
+ manualTriggerDelayMs: number;
9
+ successDelayMs: number | false;
10
+ stages: readonly AdaptiveCadenceStage[];
11
+ clock?: () => number;
12
+ };
13
+
14
+ export type AdaptiveCadenceSnapshot = {
15
+ attempt: number;
16
+ startedAt: number | null;
17
+ lastFailureAt: number | null;
18
+ lastSuccessAt: number | null;
19
+ manualTriggerPending: boolean;
20
+ completed: boolean;
21
+ };
22
+
23
+ export type AdaptiveCadenceDelayOptions = {
24
+ consumeManualTrigger?: boolean;
25
+ };
26
+
27
+ function validateStages(stages: readonly AdaptiveCadenceStage[]): void {
28
+ if (stages.length === 0) {
29
+ throw new Error('AdaptiveCadence requires at least one stage.');
30
+ }
31
+ for (const stage of stages) {
32
+ if (stage.delaysMs.length === 0) {
33
+ throw new Error('AdaptiveCadence stage requires at least one delay.');
34
+ }
35
+ }
36
+ }
37
+
38
+ export class AdaptiveCadence {
39
+ private attempt = 0;
40
+ private startedAt: number | null = null;
41
+ private lastFailureAt: number | null = null;
42
+ private lastSuccessAt: number | null = null;
43
+ private manualTriggerPending = false;
44
+ private completed = false;
45
+ private readonly clock: () => number;
46
+
47
+ constructor(private readonly options: AdaptiveCadenceOptions) {
48
+ validateStages(options.stages);
49
+ this.clock = options.clock ?? (() => Date.now());
50
+ }
51
+
52
+ recordFailure = (): void => {
53
+ const now = this.clock();
54
+ this.completed = false;
55
+ this.manualTriggerPending = false;
56
+ this.startedAt = this.startedAt ?? now;
57
+ this.lastFailureAt = now;
58
+ this.attempt += 1;
59
+ };
60
+
61
+ recordSuccess = (): void => {
62
+ this.lastSuccessAt = this.clock();
63
+ this.attempt = 0;
64
+ this.startedAt = null;
65
+ this.lastFailureAt = null;
66
+ this.manualTriggerPending = false;
67
+ this.completed = true;
68
+ };
69
+
70
+ reset = (): void => {
71
+ this.attempt = 0;
72
+ this.startedAt = null;
73
+ this.lastFailureAt = null;
74
+ this.lastSuccessAt = null;
75
+ this.manualTriggerPending = false;
76
+ this.completed = false;
77
+ };
78
+
79
+ requestManualTrigger = (): void => {
80
+ this.completed = false;
81
+ this.manualTriggerPending = true;
82
+ };
83
+
84
+ hasManualTrigger = (): boolean => this.manualTriggerPending;
85
+
86
+ getNextDelay = (
87
+ delayOptions: AdaptiveCadenceDelayOptions = {},
88
+ ): number | false => {
89
+ if (this.manualTriggerPending) {
90
+ if (delayOptions.consumeManualTrigger) {
91
+ this.manualTriggerPending = false;
92
+ }
93
+ return this.options.manualTriggerDelayMs;
94
+ }
95
+ if (this.completed) {
96
+ return this.options.successDelayMs;
97
+ }
98
+ if (this.startedAt === null) {
99
+ return this.options.idleDelayMs;
100
+ }
101
+
102
+ const stage = this.resolveStage(this.clock() - this.startedAt);
103
+ return stage.delaysMs[Math.min(this.attempt - 1, stage.delaysMs.length - 1)];
104
+ };
105
+
106
+ getSnapshot = (): AdaptiveCadenceSnapshot => ({
107
+ attempt: this.attempt,
108
+ startedAt: this.startedAt,
109
+ lastFailureAt: this.lastFailureAt,
110
+ lastSuccessAt: this.lastSuccessAt,
111
+ manualTriggerPending: this.manualTriggerPending,
112
+ completed: this.completed,
113
+ });
114
+
115
+ private resolveStage = (elapsedMs: number): AdaptiveCadenceStage =>
116
+ this.options.stages.find(
117
+ (stage) =>
118
+ stage.untilElapsedMs === undefined || elapsedMs < stage.untilElapsedMs,
119
+ ) ?? this.options.stages[this.options.stages.length - 1];
120
+ }
@@ -40,15 +40,6 @@ export function describeCronSchedule(job: CronJobView): string {
40
40
  return "-";
41
41
  }
42
42
 
43
- export function describeCronDelivery(job: CronJobView): string {
44
- if (!job.payload.deliver) {
45
- return "-";
46
- }
47
- const channel = job.payload.channel ?? "-";
48
- const target = job.payload.to ?? "-";
49
- return `${channel}:${target}`;
50
- }
51
-
52
43
  export function describeCronSession(job: CronJobView): string {
53
44
  return job.payload.sessionId?.trim() || `cron:${job.id}`;
54
45
  }