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/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
- const webRuntime = ctx.get ? ctx.get('webRuntime') : undefined
22
- const trustedHosts =
23
- webRuntime !== undefined && webRuntime !== null && Array.isArray(webRuntime.trustedHosts)
24
- ? webRuntime.trustedHosts
25
- : []
26
- const fence = (request) => isTrustedApiRequest(request, trustedHosts)
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
- return async (request, response) => {
42
- if (!fence(request)) {
43
- writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
44
- return
45
- }
46
- const url = new URL(request.url ?? '/', 'http://dsh.internal')
47
- const pathname = url.pathname
48
- const method = pathname.startsWith('/observability/api/') ? pathname.slice('/observability/api/'.length) : undefined
49
- try {
50
- const handled = await dispatchMethod(method, request, response, url, ctx, store, monitor, options)
51
- if (!handled) {
52
- writeJson(response, 404, {
53
- ok: false,
54
- error: { message: 'unknown dsh-my-observability API method' },
55
- })
56
- }
57
- } catch (error) {
58
- writeError(response, error)
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
- return method === name && request.method === verb
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
- const handled = await dispatchCore(method, request, response, url, ctx, store, monitor, options)
71
- if (handled) return true
72
- return dispatchExtended(method, request, response, url, ctx, store)
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
- if (isMethod(method, request, 'sessions', 'GET')) {
78
- writeJson(response, 200, { ok: true, value: store.sessions() })
79
- return true
80
- }
81
- if (isMethod(method, request, 'resources', 'GET')) {
82
- writeJson(response, 200, { ok: true, value: monitor.sample() })
83
- return true
84
- }
85
- if (isMethod(method, request, 'events', 'GET')) {
86
- writeJson(response, 200, {
87
- ok: true,
88
- value: store.events(queryOf(url, 'sessionId'), queryOf(url, 'type'), limitOf(url)),
89
- })
90
- return true
91
- }
92
- if (isMethod(method, request, 'status', 'GET')) {
93
- writeJson(response, 200, { ok: true, value: statusValue(store, options) })
94
- return true
95
- }
96
- if (isMethod(method, request, 'git/status', 'GET')) {
97
- await handleGitStatus(response, repoOf(url))
98
- return true
99
- }
100
- if (isMethod(method, request, 'git/diff', 'GET')) {
101
- await handleGitDiff(response, repoOf(url), url.searchParams.get('staged') === '1')
102
- return true
103
- }
104
- if (isMethod(method, request, 'git/commit', 'POST')) {
105
- await handleGitCommit(request, response)
106
- return true
107
- }
108
- if (isMethod(method, request, 'review', 'POST')) {
109
- await handleReview(ctx, request, response, options)
110
- return true
111
- }
112
- return false
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
- if (isMethod(method, request, 'plugin-status', 'GET')) {
118
- void handlePluginStatus(ctx, response)
119
- return true
120
- }
121
- if (isMethod(method, request, 'errors', 'GET')) {
122
- handleErrors(store, url, response)
123
- return true
124
- }
125
- return false
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
- return {
133
- auditCount: store.count(),
134
- sessions: store.sessions().length,
135
- aiReview: options.aiReview !== false,
136
- gitEnabled: true,
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
- * 插件状态聚合:广播 plugin:status-query 事件,收集所有插件状态。
142
- * 每个插件返回 { plugin, config, running, lastActions }(config 脱敏,lastActions ≤5)。
143
- * 超时 3s 未响应的插件标记为 { plugin, running: false, error: 'timeout' }。
136
+ * 读取插件清单(机会性:服务未激活时 ctx.get 返回 undefined → 空清单)。
137
+ *
138
+ * 官方服务是 `ctx.pluginInventory`(host 半边),`list()` 是 **async**——
139
+ * 同步解引用 `entries` 会抛错。清单名取 `entry.moduleName`(**完整包名**),
140
+ * 因为监听方正是按完整包名比对自己。
144
141
  */
145
- async function handlePluginStatus(ctx, response) {
146
- const TIMEOUT_MS = 3000
147
- const results = []
148
- try {
149
- // 广播查询事件,带超时
150
- const statuses = await Promise.allSettled(
151
- (ctx.bundler?.plugins ?? []).map(async (p) => {
152
- const name = p.name ?? p.constructor?.name ?? 'unknown'
153
- try {
154
- const result = await Promise.race([
155
- ctx.emit('plugin:status-query', { plugin: name }),
156
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS)),
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
- writeJson(response, 200, { ok: true, value: store.events(null, 'plugin_error', limitOf(url)) })
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
- if (repoPath === '') {
181
- writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } })
182
- return
183
- }
184
- const result = await gitStatus(repoPath)
185
- if (!result.ok) {
186
- writeJson(response, 400, { ok: false, error: result.error })
187
- return
188
- }
189
- writeJson(response, 200, { ok: true, value: result })
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
- if (repoPath === '') {
195
- writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } })
196
- return
197
- }
198
- const result = await gitDiff(repoPath, staged)
199
- if (!result.ok) {
200
- writeJson(response, 400, { ok: false, error: result.error })
201
- return
202
- }
203
- writeJson(response, 200, { ok: true, value: { text: result.text } })
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
- const payload = await readJsonBody(request)
209
- const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : ''
210
- if (repoPath === '') {
211
- writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } })
212
- return
213
- }
214
- const result = await gitCommit(repoPath, payload)
215
- if (!result.ok) {
216
- writeJson(response, 400, { ok: false, error: result.error })
217
- return
218
- }
219
- writeJson(response, 200, {
220
- ok: true,
221
- value: { hash: result.hash, message: result.message, summary: result.summary },
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
- const payload = await readJsonBody(request)
228
- const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : ''
229
- if (repoPath === '') {
230
- writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } })
231
- return
232
- }
233
- const staged = payload.staged === true
234
- const diffResult = await gitDiff(repoPath, staged)
235
- if (!diffResult.ok) {
236
- writeJson(response, 400, { ok: false, error: diffResult.error })
237
- return
238
- }
239
- const report = reviewRules(parseDiff(diffResult.text))
240
- report.ai = await aiOutcome(ctx, payload, options, diffResult.text, report)
241
- writeJson(response, 200, { ok: true, value: report })
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
- if (options.aiReview === false) return { enabled: false }
247
- if (payload.aiReview === false) return { enabled: false }
248
- return runAiReview(ctx, diffText, report, options.aiTimeoutMs)
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
- return url.searchParams.get(name) ?? ''
279
+ return url.searchParams.get(name) ?? '';
255
280
  }
256
-
257
281
  function repoOf(url) {
258
- const repo = url.searchParams.get('repo')
259
- return typeof repo === 'string' ? repo : ''
282
+ const repo = url.searchParams.get('repo');
283
+ return typeof repo === 'string' ? repo : '';
260
284
  }
261
-
262
285
  function limitOf(url) {
263
- const raw = url.searchParams.get('limit')
264
- const parsed = raw === null ? 0 : Number(raw)
265
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
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
  }