dsh-my-observability 0.3.0 → 0.3.2
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 +9 -79
- package/README.md +18 -76
- package/lib/ai.js +123 -99
- package/lib/audit-view.js +173 -172
- package/lib/audit.js +175 -166
- package/lib/client.js +1024 -1278
- package/lib/client.src.js +55 -29
- package/lib/constants.js +4 -4
- package/lib/diff.js +61 -56
- package/lib/git.js +97 -98
- package/lib/index.js +46 -47
- package/lib/parts/native-tabs.js +45 -0
- package/lib/resource-monitor.js +82 -134
- package/lib/resource-rules.js +9 -99
- package/lib/review.js +68 -77
- package/lib/routes.js +223 -200
- package/lib/store-persist.js +114 -111
- package/lib/store.js +215 -212
- package/package.json +15 -7
- package/lib/parts/git.js +0 -298
- package/lib/parts/i18n.js +0 -104
- package/lib/parts/replay-ext.js +0 -396
- package/lib/parts/replay.js +0 -386
- package/lib/parts/resource.js +0 -80
- package/lib/parts/styles.js +0 -156
package/lib/routes.js
CHANGED
|
@@ -10,257 +10,280 @@
|
|
|
10
10
|
* - POST /git/commit — 类型化提交(Conventional Commits)
|
|
11
11
|
* - POST /review — 增量 diff 审查(规则引擎 + 可选 AI)
|
|
12
12
|
*/
|
|
13
|
-
import { isTrustedApiRequest, readJsonBody, writeJson, writeError } from 'dsh-shared'
|
|
14
|
-
import { gitStatus, gitDiff, gitCommit } from './git.js'
|
|
15
|
-
import { parseDiff } from './diff.js'
|
|
16
|
-
import { reviewRules } from './review.js'
|
|
17
|
-
import { runAiReview } from './ai.js'
|
|
18
|
-
|
|
13
|
+
import { isTrustedApiRequest, readJsonBody, writeJson, writeError } from 'dsh-shared';
|
|
14
|
+
import { gitStatus, gitDiff, gitCommit } from './git.js';
|
|
15
|
+
import { parseDiff } from './diff.js';
|
|
16
|
+
import { reviewRules } from './review.js';
|
|
17
|
+
import { runAiReview } from './ai.js';
|
|
19
18
|
/** 注册 /observability/api 路由(effect 持有 disposer)。 */
|
|
20
19
|
export function registerObservabilityRoutes(ctx, store, monitor, options) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ctx.effect(
|
|
29
|
-
() =>
|
|
30
|
-
ctx.webServer.register({
|
|
20
|
+
const webRuntime = ctx.get ? ctx.get('webRuntime') : undefined;
|
|
21
|
+
const trustedHosts = webRuntime !== undefined && webRuntime !== null && Array.isArray(webRuntime.trustedHosts)
|
|
22
|
+
? webRuntime.trustedHosts
|
|
23
|
+
: [];
|
|
24
|
+
const fence = (request) => isTrustedApiRequest(request, trustedHosts);
|
|
25
|
+
ctx.effect(() => ctx.webServer.register({
|
|
31
26
|
kind: 'prefix',
|
|
32
27
|
path: '/observability/api',
|
|
33
28
|
handler: apiHandler(ctx, fence, store, monitor, options),
|
|
34
|
-
|
|
35
|
-
'dsh-my-observability: /observability/api routes',
|
|
36
|
-
)
|
|
29
|
+
}), 'dsh-my-observability: /observability/api routes');
|
|
37
30
|
}
|
|
38
|
-
|
|
39
31
|
/** 统一 handler:fence → 方法分派 → 404/错误兜底。 */
|
|
40
32
|
function apiHandler(ctx, fence, store, monitor, options) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
33
|
+
return async (request, response) => {
|
|
34
|
+
if (!fence(request)) {
|
|
35
|
+
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const url = new URL(request.url ?? '/', 'http://dsh.internal');
|
|
39
|
+
const pathname = url.pathname;
|
|
40
|
+
const method = pathname.startsWith('/observability/api/') ? pathname.slice('/observability/api/'.length) : undefined;
|
|
41
|
+
try {
|
|
42
|
+
// 查询语义与「磁盘历史加载耗时」解耦:就绪前不派发,避免读到半加载
|
|
43
|
+
// 状态(曾经的 CI flaky:固定 40ms sleep 赌 readFile 跑完,慢机器上
|
|
44
|
+
// 查询读到 0 条事件 → 0 !== 1)。whenReady 就绪后立即 resolve,无延迟。
|
|
45
|
+
await store.whenReady();
|
|
46
|
+
const handled = await dispatchMethod(method, request, response, url, ctx, store, monitor, options);
|
|
47
|
+
if (!handled) {
|
|
48
|
+
writeJson(response, 404, {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: { message: 'unknown dsh-my-observability API method' },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
writeError(response, error);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
61
58
|
}
|
|
62
|
-
|
|
63
59
|
/** 方法 + 请求动词匹配。 */
|
|
64
60
|
function isMethod(method, request, name, verb) {
|
|
65
|
-
|
|
61
|
+
return method === name && request.method === verb;
|
|
66
62
|
}
|
|
67
|
-
|
|
68
63
|
/** 按 method 分派到具体 handler;未识别返回 false(调用方回 404)。 */
|
|
69
64
|
async function dispatchMethod(method, request, response, url, ctx, store, monitor, options) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
const handled = await dispatchCore(method, request, response, url, ctx, store, monitor, options);
|
|
66
|
+
if (handled)
|
|
67
|
+
return true;
|
|
68
|
+
return dispatchExtended(method, request, response, url, ctx, store);
|
|
73
69
|
}
|
|
74
|
-
|
|
75
70
|
/** 核心路由(复杂度 ≤10)。 */
|
|
76
71
|
async function dispatchCore(method, request, response, url, ctx, store, monitor, options) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
72
|
+
if (isMethod(method, request, 'sessions', 'GET')) {
|
|
73
|
+
writeJson(response, 200, { ok: true, value: store.sessions() });
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (isMethod(method, request, 'resources', 'GET')) {
|
|
77
|
+
writeJson(response, 200, { ok: true, value: monitor.sample() });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (isMethod(method, request, 'events', 'GET')) {
|
|
81
|
+
writeJson(response, 200, {
|
|
82
|
+
ok: true,
|
|
83
|
+
value: store.events(queryOf(url, 'sessionId'), queryOf(url, 'type'), limitOf(url)),
|
|
84
|
+
});
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (isMethod(method, request, 'status', 'GET')) {
|
|
88
|
+
writeJson(response, 200, { ok: true, value: statusValue(store, options) });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (isMethod(method, request, 'git/status', 'GET')) {
|
|
92
|
+
await handleGitStatus(response, repoOf(url));
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (isMethod(method, request, 'git/diff', 'GET')) {
|
|
96
|
+
await handleGitDiff(response, repoOf(url), url.searchParams.get('staged') === '1');
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (isMethod(method, request, 'git/commit', 'POST')) {
|
|
100
|
+
await handleGitCommit(request, response);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (isMethod(method, request, 'review', 'POST')) {
|
|
104
|
+
await handleReview(ctx, request, response, options);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
113
108
|
}
|
|
114
|
-
|
|
115
109
|
/** 扩展路由(#154/#155 新增)。 */
|
|
116
110
|
function dispatchExtended(method, request, response, url, ctx, store) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
if (isMethod(method, request, 'plugin-status', 'GET')) {
|
|
112
|
+
void handlePluginStatus(ctx, response);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (isMethod(method, request, 'errors', 'GET')) {
|
|
116
|
+
handleErrors(store, url, response);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
126
120
|
}
|
|
127
|
-
|
|
128
121
|
// ── handlers ───────────────────────────────────────────────────────────────
|
|
129
|
-
|
|
130
122
|
/** 状态:审计统计 + 功能开关(aiReview 只暴露开关)。 */
|
|
131
123
|
function statusValue(store, options) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
124
|
+
return {
|
|
125
|
+
auditCount: store.count(),
|
|
126
|
+
sessions: store.sessions().length,
|
|
127
|
+
aiReview: options.aiReview !== false,
|
|
128
|
+
gitEnabled: true,
|
|
129
|
+
};
|
|
138
130
|
}
|
|
139
|
-
|
|
131
|
+
/** 聚合查询默认超时(3s):超时未响应的插件标记 { running: false, error: 'timeout' }。 */
|
|
132
|
+
export const PLUGIN_STATUS_TIMEOUT_MS = 3000;
|
|
133
|
+
/** 超时哨兵(区分「超时」与「监听方返回值恰好是 undefined」)。 */
|
|
134
|
+
const STATUS_TIMEOUT = Symbol('plugin-status-timeout');
|
|
140
135
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
136
|
+
* 读取插件清单(机会性:服务未激活时 ctx.get 返回 undefined → 空清单)。
|
|
137
|
+
*
|
|
138
|
+
* 官方服务是 `ctx.pluginInventory`(host 半边),`list()` 是 **async**——
|
|
139
|
+
* 同步解引用 `entries` 会抛错。清单名取 `entry.moduleName`(**完整包名**),
|
|
140
|
+
* 因为监听方正是按完整包名比对自己。
|
|
144
141
|
*/
|
|
145
|
-
async function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
])
|
|
158
|
-
return result ?? { plugin: name, running: true, config: {}, lastActions: [] }
|
|
159
|
-
} catch {
|
|
160
|
-
return { plugin: name, running: false, error: 'timeout' }
|
|
161
|
-
}
|
|
162
|
-
}),
|
|
163
|
-
)
|
|
164
|
-
for (const s of statuses) {
|
|
165
|
-
results.push(s.status === 'fulfilled' ? s.value : { plugin: 'unknown', running: false, error: 'rejected' })
|
|
142
|
+
async function readPluginNames(ctx) {
|
|
143
|
+
const service = ctx.get('pluginInventory');
|
|
144
|
+
if (service === undefined || service === null)
|
|
145
|
+
return [];
|
|
146
|
+
try {
|
|
147
|
+
const snapshot = await service.list();
|
|
148
|
+
return (snapshot?.entries ?? [])
|
|
149
|
+
.map((entry) => entry.moduleName)
|
|
150
|
+
.filter((name) => typeof name === 'string' && name !== '');
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return [];
|
|
166
154
|
}
|
|
167
|
-
} catch {
|
|
168
|
-
// bundler 不可用时返回空列表
|
|
169
|
-
}
|
|
170
|
-
writeJson(response, 200, { ok: true, value: results })
|
|
171
155
|
}
|
|
172
|
-
|
|
156
|
+
/** 从分发结果挑出匹配本插件的 value(`ok === true` 且 `value.plugin` 同名)。 */
|
|
157
|
+
function pickStatusValue(collected, name) {
|
|
158
|
+
for (const item of Array.isArray(collected) ? collected : [collected]) {
|
|
159
|
+
const response = item;
|
|
160
|
+
if (response?.ok === true && response.value?.plugin === name)
|
|
161
|
+
return response.value;
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 单插件状态查询:用 `ctx.serial` 收集监听方返回值。
|
|
167
|
+
*
|
|
168
|
+
* cordis 三种派发对返回值的处理各不相同(本机 @deepseek-ai/cordis@4.0.2 实测):
|
|
169
|
+
* `emit` 同步派发**不收集**;`parallel` 只处理异常、**不返回结果**(Promise<void>);
|
|
170
|
+
* 只有 `serial` 顺序 await 并返回首个非 null/false/undefined 的返回值。逐插件
|
|
171
|
+
* 查询时非匹配监听方一律 `return undefined`,因此 serial 恰好命中唯一那一条。
|
|
172
|
+
* 无匹配响应一律如实标记(`no-response`),**绝不编造 running: true**。
|
|
173
|
+
*/
|
|
174
|
+
async function queryPluginStatus(ctx, name, timeoutMs) {
|
|
175
|
+
// 期望态包装成永不 reject:超时后已丢弃的分发不得变成 unhandled rejection。
|
|
176
|
+
const dispatch = Promise.resolve()
|
|
177
|
+
.then(() => ctx.serial('plugin:status-query', { plugin: name }))
|
|
178
|
+
.then((value) => value, () => undefined);
|
|
179
|
+
const timer = new Promise((resolve) => {
|
|
180
|
+
const handle = setTimeout(() => resolve(STATUS_TIMEOUT), timeoutMs);
|
|
181
|
+
handle.unref?.();
|
|
182
|
+
});
|
|
183
|
+
const outcome = await Promise.race([dispatch, timer]);
|
|
184
|
+
if (outcome === STATUS_TIMEOUT)
|
|
185
|
+
return { plugin: name, running: false, error: 'timeout' };
|
|
186
|
+
return pickStatusValue(outcome, name) ?? { plugin: name, running: false, error: 'no-response' };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 插件状态聚合:按插件清单逐个查询 `plugin:status-query`,汇总监听方真实状态。
|
|
190
|
+
* 每条为监听方返回的 { plugin, config, running, stats?, lastActions? };超时 3s
|
|
191
|
+
* 未响应标 { running: false, error: 'timeout' },无匹配响应标 no-response。
|
|
192
|
+
* 清单不可用(服务未激活)时返回空列表 `{ ok: true, value: [] }`。
|
|
193
|
+
*/
|
|
194
|
+
export async function handlePluginStatus(ctx, response, timeoutMs = PLUGIN_STATUS_TIMEOUT_MS) {
|
|
195
|
+
const names = await readPluginNames(ctx);
|
|
196
|
+
const results = await Promise.all(names.map((name) => queryPluginStatus(ctx, name, timeoutMs)));
|
|
197
|
+
writeJson(response, 200, { ok: true, value: results });
|
|
198
|
+
}
|
|
173
199
|
/** 错误事件查询(#155 错误上报统一):返回 plugin_error 类型事件。 */
|
|
174
200
|
function handleErrors(store, url, response) {
|
|
175
|
-
|
|
201
|
+
writeJson(response, 200, { ok: true, value: store.events(null, 'plugin_error', limitOf(url)) });
|
|
176
202
|
}
|
|
177
|
-
|
|
178
203
|
/** git status:非仓库路径 400。 */
|
|
179
204
|
async function handleGitStatus(response, repoPath) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
205
|
+
if (repoPath === '') {
|
|
206
|
+
writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const result = await gitStatus(repoPath);
|
|
210
|
+
if (!result.ok) {
|
|
211
|
+
writeJson(response, 400, { ok: false, error: result.error });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
writeJson(response, 200, { ok: true, value: result });
|
|
190
215
|
}
|
|
191
|
-
|
|
192
216
|
/** git diff:非仓库路径 400。 */
|
|
193
217
|
async function handleGitDiff(response, repoPath, staged) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
218
|
+
if (repoPath === '') {
|
|
219
|
+
writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const result = await gitDiff(repoPath, staged);
|
|
223
|
+
if (!result.ok) {
|
|
224
|
+
writeJson(response, 400, { ok: false, error: result.error });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
writeJson(response, 200, { ok: true, value: { text: result.text } });
|
|
204
228
|
}
|
|
205
|
-
|
|
206
229
|
/** 类型化提交:body { repoPath, type, scope, description, body }。 */
|
|
207
230
|
async function handleGitCommit(request, response) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
231
|
+
const payload = await readJsonBody(request);
|
|
232
|
+
const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : '';
|
|
233
|
+
if (repoPath === '') {
|
|
234
|
+
writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const result = await gitCommit(repoPath, payload);
|
|
238
|
+
if (!result.ok) {
|
|
239
|
+
writeJson(response, 400, { ok: false, error: result.error });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
writeJson(response, 200, {
|
|
243
|
+
ok: true,
|
|
244
|
+
value: { hash: result.hash, message: result.message, summary: result.summary },
|
|
245
|
+
});
|
|
223
246
|
}
|
|
224
|
-
|
|
225
247
|
/** 增量 diff 审查:规则引擎 + 可选 AI 增强(失败不影响规则结果)。 */
|
|
226
248
|
async function handleReview(ctx, request, response, options) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
249
|
+
const payload = await readJsonBody(request);
|
|
250
|
+
const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : '';
|
|
251
|
+
if (repoPath === '') {
|
|
252
|
+
writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const staged = payload.staged === true;
|
|
256
|
+
const diffResult = await gitDiff(repoPath, staged);
|
|
257
|
+
if (!diffResult.ok) {
|
|
258
|
+
writeJson(response, 400, { ok: false, error: diffResult.error });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const report = reviewRules(parseDiff(diffResult.text));
|
|
262
|
+
report.ai = await aiOutcome(ctx, payload, options, diffResult.text, report);
|
|
263
|
+
writeJson(response, 200, { ok: true, value: report });
|
|
242
264
|
}
|
|
243
|
-
|
|
244
265
|
/** AI 审查开关判定:配置开启 + 请求未显式关闭 → 运行(失败降级)。 */
|
|
245
266
|
async function aiOutcome(ctx, payload, options, diffText, report) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
267
|
+
if (options.aiReview === false)
|
|
268
|
+
return { enabled: false };
|
|
269
|
+
if (payload.aiReview === false)
|
|
270
|
+
return { enabled: false };
|
|
271
|
+
return runAiReview(ctx, diffText, report, options.aiTimeoutMs, {
|
|
272
|
+
provider: options.aiProvider,
|
|
273
|
+
model: options.aiModel,
|
|
274
|
+
cwd: options.aiCwd,
|
|
275
|
+
});
|
|
249
276
|
}
|
|
250
|
-
|
|
251
277
|
// ── HTTP helpers ───────────────────────────────────────────────────────────
|
|
252
|
-
|
|
253
278
|
function queryOf(url, name) {
|
|
254
|
-
|
|
279
|
+
return url.searchParams.get(name) ?? '';
|
|
255
280
|
}
|
|
256
|
-
|
|
257
281
|
function repoOf(url) {
|
|
258
|
-
|
|
259
|
-
|
|
282
|
+
const repo = url.searchParams.get('repo');
|
|
283
|
+
return typeof repo === 'string' ? repo : '';
|
|
260
284
|
}
|
|
261
|
-
|
|
262
285
|
function limitOf(url) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
286
|
+
const raw = url.searchParams.get('limit');
|
|
287
|
+
const parsed = raw === null ? 0 : Number(raw);
|
|
288
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
266
289
|
}
|