@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.
- package/CHANGELOG.md +22 -8
- package/dist/assets/{api-BCDb13Uv.js → api-CIJxdHmy.js} +5 -5
- package/dist/assets/app-presenter-provider-CKZqZfv5.js +3 -0
- package/dist/assets/arrow-left-DHcD-tbC.js +1 -0
- package/dist/assets/channels-list-page-C_Xe0CNX.js +8 -0
- package/dist/assets/chat-page-CXRtoQR2.js +105 -0
- package/dist/assets/{config-split-page-BwhGqmUS.js → config-split-page-DBAcw9mJ.js} +1 -1
- package/dist/assets/{confirm-dialog-DBkKcQ1q.js → confirm-dialog-DwPTxq8C.js} +1 -1
- package/dist/assets/{desktop-update-config-BE9KjiXi.js → desktop-update-config-BqABa_1C.js} +1 -1
- package/dist/assets/{dist-BI0bX8jv.js → dist-BfcibKyv.js} +1 -1
- package/dist/assets/{dist-C8JRt-tf.js → dist-WjhKB-pc.js} +1 -1
- package/dist/assets/{doc-browser-eqdNEerh.js → doc-browser-8EEDwGCJ.js} +1 -1
- package/dist/assets/doc-browser-DlT90zQD.js +1 -0
- package/dist/assets/{doc-browser-context-VHUOIUXL.js → doc-browser-context-DLJOy6Am.js} +1 -1
- package/dist/assets/{ellipsis-C9VB2CxT.js → ellipsis-H-4aNwgt.js} +1 -1
- package/dist/assets/{es2015-BCw3Q7nY.js → es2015-B5k1IpKX.js} +1 -1
- package/dist/assets/{external-link-Bp7_4s9t.js → external-link-BN1wd-eI.js} +1 -1
- package/dist/assets/{index-DTdPDqto.js → index-WJt0X9wC.js} +5 -5
- package/dist/assets/{key-round-Ccy6oND3.js → key-round-D-7IaAQS.js} +1 -1
- package/dist/assets/{loader-circle-OpH4EErP.js → loader-circle-CB38Q7Ws.js} +1 -1
- package/dist/assets/mcp-marketplace-page-CmZwt64F.js +1 -0
- package/dist/assets/{mcp-marketplace-page-B-GxKOqS.js → mcp-marketplace-page-ju1KH3TG.js} +2 -2
- package/dist/assets/{model-config-BXPnV_7n.js → model-config-CgS6dp65.js} +1 -1
- package/dist/assets/{notice-card-Dsbkl7u7.js → notice-card-DW9BLKpG.js} +1 -1
- package/dist/assets/play-DGpH8jeI.js +1 -0
- package/dist/assets/plus-CkcQjwuH.js +1 -0
- package/dist/assets/{popover-BTLZE5Iz.js → popover-ofN845gZ.js} +1 -1
- package/dist/assets/{provider-scoped-model-input-DKI9KN7N.js → provider-scoped-model-input-DaDqi8Zy.js} +1 -1
- package/dist/assets/providers-list-CZNQXY4n.js +1 -0
- package/dist/assets/{react-BqS8oMyM.js → react-Bq34NL59.js} +1 -1
- package/dist/assets/{refresh-cw-BIaj7txb.js → refresh-cw-DJttSXS8.js} +1 -1
- package/dist/assets/remote-CYmsM9DD.js +1 -0
- package/dist/assets/rotate-cw-D_xslHna.js +1 -0
- package/dist/assets/{runtime-config-page-DX_wSK1J.js → runtime-config-page-gUjM6Z_A.js} +1 -1
- package/dist/assets/{save-BEoORten.js → save-DtAFbS7x.js} +1 -1
- package/dist/assets/search-CNMRwAFx.js +1 -0
- package/dist/assets/{search-config-DA5By-tE.js → search-config-C04PrmHj.js} +1 -1
- package/dist/assets/{secrets-config-eDxG7iN4.js → secrets-config-DvxHdz9h.js} +2 -2
- package/dist/assets/{status-dot-oWBjTovo.js → status-dot-D4jbKDLB.js} +1 -1
- package/dist/assets/{tabs-CGaY0BVO.js → tabs-Ca2vw7YQ.js} +1 -1
- package/dist/assets/{tabs-custom-D2fPYb4G.js → tabs-custom-DzGDjYxf.js} +1 -1
- package/dist/assets/{tag-chip-TVkgofS1.js → tag-chip-CW-_pHjH.js} +1 -1
- package/dist/assets/{tooltip-BwHPKBv9.js → tooltip-Cq2mERPk.js} +1 -1
- package/dist/assets/{trash-2-D3psAGrz.js → trash-2-rumaEu9l.js} +1 -1
- package/dist/assets/x-DGqjdMUt.js +1 -0
- package/dist/index.html +24 -24
- package/package.json +8 -8
- package/src/app/components/layout/runtime-status-entry.test.tsx +5 -0
- package/src/app/components/layout/runtime-status-entry.tsx +8 -0
- package/src/features/chat/components/conversation/chat-message-list.container.tsx +0 -6
- package/src/features/chat/components/workspace/session-cron-job-content.tsx +3 -3
- package/src/features/chat/hooks/use-chat-session-update.test.tsx +2 -1
- package/src/features/chat/managers/chat-session-preference-sync.manager.test.ts +2 -1
- package/src/features/chat/utils/chat-input-bar.utils.ts +0 -2
- package/src/features/chat/utils/chat-session-preference-governance.utils.ts +4 -1
- package/src/features/marketplace/components/mcp/mcp-marketplace-page.test.tsx +2 -1
- package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +125 -119
- package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +47 -17
- package/src/features/service-apps/components/service-action-authorization-dialog.tsx +38 -5
- package/src/features/service-apps/stores/service-action-authorization.store.ts +7 -3
- package/src/features/system-status/components/desktop-update-config.test.tsx +2 -1
- package/src/features/system-status/hooks/use-system-status.ts +1 -4
- package/src/features/system-status/managers/system-status.manager.bootstrap-polling.test.ts +137 -100
- package/src/features/system-status/managers/system-status.manager.ts +38 -20
- package/src/shared/components/cron-config.tsx +1 -5
- package/src/shared/lib/api/types.ts +0 -4
- package/src/shared/lib/cadence/index.ts +7 -0
- package/src/shared/lib/cadence/services/adaptive-cadence.service.test.ts +95 -0
- package/src/shared/lib/cadence/services/adaptive-cadence.service.ts +120 -0
- package/src/shared/lib/cron/cron-job-view.utils.ts +0 -9
- package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +4 -3
- package/src/shared/lib/i18n/runtime/i18n-language-owner.test.ts +3 -2
- package/dist/assets/app-presenter-provider-C-QMjK7m.js +0 -3
- package/dist/assets/arrow-left-rIXFA2Zy.js +0 -1
- package/dist/assets/channels-list-page-DnqwsKGe.js +0 -8
- package/dist/assets/chat-page-CsQx20xL.js +0 -105
- package/dist/assets/doc-browser-lpF_pyx4.js +0 -1
- package/dist/assets/mcp-marketplace-page-Dm-0-w51.js +0 -1
- package/dist/assets/play-CvgYhNbo.js +0 -1
- package/dist/assets/plus-DHcP-K1U.js +0 -1
- package/dist/assets/providers-list-Bko6B7dE.js +0 -1
- package/dist/assets/remote-DHn_b8QU.js +0 -1
- package/dist/assets/rotate-cw-DMqSkRJQ.js +0 -1
- package/dist/assets/search-COFh_SFI.js +0 -1
- 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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
).toBe(
|
|
122
|
+
systemStatusManager.getRuntimeBootstrapPollInterval(undefined)
|
|
123
|
+
).toBe(1000);
|
|
124
|
+
|
|
125
|
+
systemStatusManager.reportBootstrapQueryError(new Error('Failed to fetch'));
|
|
131
126
|
expect(
|
|
132
|
-
systemStatusManager.getRuntimeBootstrapPollInterval(undefined
|
|
133
|
-
).toBe(
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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,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
|
}
|