dsh-my-observability 0.1.0
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 +20 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/assets/git-panel.png +0 -0
- package/assets/replay-panel.png +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/ai.js +165 -0
- package/lib/audit.js +150 -0
- package/lib/client.js +636 -0
- package/lib/client.src.js +62 -0
- package/lib/constants.js +7 -0
- package/lib/diff.js +86 -0
- package/lib/fence.js +60 -0
- package/lib/git.js +142 -0
- package/lib/index.js +48 -0
- package/lib/parts/git.js +240 -0
- package/lib/parts/i18n.js +67 -0
- package/lib/parts/replay.js +199 -0
- package/lib/parts/styles.js +68 -0
- package/lib/review.js +126 -0
- package/lib/routes.js +215 -0
- package/lib/store.js +231 -0
- package/package.json +59 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ── i18n(浏览器语言判定)──────────────────────────────────────────
|
|
2
|
+
function isZh() {
|
|
3
|
+
try {
|
|
4
|
+
const lang = (navigator.language || 'en').toLowerCase()
|
|
5
|
+
return lang.startsWith('zh')
|
|
6
|
+
} catch {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const strings = {
|
|
12
|
+
replayTitle: () => (isZh() ? '轨迹回放' : 'Trajectory'),
|
|
13
|
+
gitTitle: () => (isZh() ? 'Git 工具' : 'Git Tools'),
|
|
14
|
+
allSessions: () => (isZh() ? '全部会话' : 'All sessions'),
|
|
15
|
+
filterAll: () => (isZh() ? '全部' : 'All'),
|
|
16
|
+
filterStatus: () => (isZh() ? '状态' : 'Status'),
|
|
17
|
+
filterLlm: () => (isZh() ? '模型流' : 'LLM'),
|
|
18
|
+
filterTools: () => (isZh() ? '工具' : 'Tools'),
|
|
19
|
+
emptyEvents: () => (isZh() ? '暂无审计事件——开始一段对话后,agent 的行为会出现在这里' : 'No audit events yet — agent activity will appear here after a conversation'),
|
|
20
|
+
loadError: () => (isZh() ? '加载失败' : 'Load failed'),
|
|
21
|
+
typeAgentStatus: () => (isZh() ? 'agent 状态' : 'agent status'),
|
|
22
|
+
typeLlmStream: () => (isZh() ? '模型流' : 'LLM stream'),
|
|
23
|
+
typeToolCall: () => (isZh() ? '工具调用' : 'tool call'),
|
|
24
|
+
typeToolResult: () => (isZh() ? '工具结果' : 'tool result'),
|
|
25
|
+
phaseStart: () => (isZh() ? '开始' : 'start'),
|
|
26
|
+
phaseEnd: () => (isZh() ? '结束' : 'end'),
|
|
27
|
+
phaseError: () => (isZh() ? '错误' : 'error'),
|
|
28
|
+
agentTop: () => (isZh() ? '顶层' : 'top'),
|
|
29
|
+
agentSub: () => (isZh() ? '子代理' : 'subagent'),
|
|
30
|
+
agentUnknown: () => (isZh() ? '未知' : 'unknown'),
|
|
31
|
+
toolOk: () => (isZh() ? '成功' : 'ok'),
|
|
32
|
+
toolFail: () => (isZh() ? '失败' : 'failed'),
|
|
33
|
+
// Git 面板
|
|
34
|
+
repoLabel: () => (isZh() ? '仓库路径' : 'Repo path'),
|
|
35
|
+
repoPlaceholder: () => (isZh() ? '如 /path/to/project' : 'e.g. /path/to/project'),
|
|
36
|
+
loadRepo: () => (isZh() ? '加载' : 'Load'),
|
|
37
|
+
branch: () => (isZh() ? '分支' : 'Branch'),
|
|
38
|
+
staged: () => (isZh() ? '已暂存' : 'staged'),
|
|
39
|
+
unstaged: () => (isZh() ? '未暂存' : 'unstaged'),
|
|
40
|
+
clean: () => (isZh() ? '工作区干净' : 'Working tree clean'),
|
|
41
|
+
diffTitle: () => (isZh() ? '差异' : 'Diff'),
|
|
42
|
+
showDiff: () => (isZh() ? '查看差异' : 'Show diff'),
|
|
43
|
+
showStagedDiff: () => (isZh() ? '查看暂存差异' : 'Staged diff'),
|
|
44
|
+
noChanges: () => (isZh() ? '没有变更' : 'No changes'),
|
|
45
|
+
review: () => (isZh() ? '提交前审查' : 'Review'),
|
|
46
|
+
reviewAi: () => (isZh() ? 'AI 审查' : 'AI review'),
|
|
47
|
+
reviewResult: () => (isZh() ? '审查结果' : 'Review result'),
|
|
48
|
+
reviewPass: () => (isZh() ? '未发现问题' : 'No issues found'),
|
|
49
|
+
issues: (count) => (isZh() ? `${count} 个问题` : `${count} issue(s)`),
|
|
50
|
+
commitTitle: () => (isZh() ? '类型化提交' : 'Typed commit'),
|
|
51
|
+
commitType: () => (isZh() ? '类型' : 'Type'),
|
|
52
|
+
commitScope: () => (isZh() ? '范围(可选)' : 'Scope (optional)'),
|
|
53
|
+
commitDesc: () => (isZh() ? '描述' : 'Description'),
|
|
54
|
+
commitBody: () => (isZh() ? '正文(可选)' : 'Body (optional)'),
|
|
55
|
+
commit: () => (isZh() ? '提交' : 'Commit'),
|
|
56
|
+
committed: () => (isZh() ? '已提交' : 'Committed'),
|
|
57
|
+
commitError: () => (isZh() ? '提交失败' : 'Commit failed'),
|
|
58
|
+
severityError: () => (isZh() ? '错误' : 'Error'),
|
|
59
|
+
severityWarning: () => (isZh() ? '警告' : 'Warning'),
|
|
60
|
+
severityInfo: () => (isZh() ? '提示' : 'Info'),
|
|
61
|
+
aiVerdictApprove: () => (isZh() ? 'AI 结论:可以提交' : 'AI verdict: approve'),
|
|
62
|
+
aiVerdictChanges: () => (isZh() ? 'AI 结论:建议修改' : 'AI verdict: changes'),
|
|
63
|
+
aiFailed: () => (isZh() ? 'AI 审查不可用' : 'AI review unavailable'),
|
|
64
|
+
loading: () => (isZh() ? '加载中…' : 'Loading…'),
|
|
65
|
+
emptyDiff: () => (isZh() ? '(空)' : '(empty)'),
|
|
66
|
+
noRepo: () => (isZh() ? '请输入仓库路径' : 'Enter a repo path'),
|
|
67
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// ── 轨迹回放面板(时间轴)──────────────────────────────────────────
|
|
2
|
+
const REPLAY_POLL_MS = 5000
|
|
3
|
+
|
|
4
|
+
/** 请求插件 API(非 2xx 抛错;返回响应 JSON 的 value 字段)。 */
|
|
5
|
+
function apiJson(path, options) {
|
|
6
|
+
return fetch(path, options).then(async (res) => {
|
|
7
|
+
const data = await res.json()
|
|
8
|
+
if (!res.ok) throw new Error(data.error?.message || `HTTP ${res.status}`)
|
|
9
|
+
return data.value
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 事件类型 → 中文标签。 */
|
|
14
|
+
function typeLabel(event) {
|
|
15
|
+
switch (event.type) {
|
|
16
|
+
case 'agent_status': return strings.typeAgentStatus()
|
|
17
|
+
case 'llm_stream': return strings.typeLlmStream()
|
|
18
|
+
case 'tool_call': return strings.typeToolCall()
|
|
19
|
+
case 'tool_result': return strings.typeToolResult()
|
|
20
|
+
default: return event.type
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 事件类型 → 徽标样式类别。 */
|
|
25
|
+
function badgeKind(event) {
|
|
26
|
+
if (event.type === 'agent_status') return 'status'
|
|
27
|
+
if (event.type === 'llm_stream') return 'llm'
|
|
28
|
+
return 'tool'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 时间戳 → HH:MM:SS。 */
|
|
32
|
+
function timeText(time) {
|
|
33
|
+
try {
|
|
34
|
+
const date = new Date(time)
|
|
35
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
36
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
37
|
+
} catch {
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** agent 类型标记 → 中文。 */
|
|
43
|
+
function agentTypeText(agentType) {
|
|
44
|
+
if (agentType === 'top') return strings.agentTop()
|
|
45
|
+
if (agentType === 'subagent') return strings.agentSub()
|
|
46
|
+
return strings.agentUnknown()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 模型流阶段 → 中文。 */
|
|
50
|
+
function phaseText(phase) {
|
|
51
|
+
if (phase === 'start') return strings.phaseStart()
|
|
52
|
+
if (phase === 'end') return strings.phaseEnd()
|
|
53
|
+
if (phase === 'error') return strings.phaseError()
|
|
54
|
+
return phase
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** agent 状态事件摘要。 */
|
|
58
|
+
function agentMeta(data) {
|
|
59
|
+
return `状态 ${data.status} · ${agentTypeText(data.agentType)}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 模型流事件摘要(开始/结束/错误 + 统计)。 */
|
|
63
|
+
function llmMeta(data) {
|
|
64
|
+
const stats = data.phase === 'start' ? '' : ` · ${data.chunks} chunks / ${data.chars} chars / ${data.ms}ms`
|
|
65
|
+
const error = data.message !== undefined ? `:${data.message}` : ''
|
|
66
|
+
return `${phaseText(data.phase)}${stats}${error}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 工具调用事件摘要(名称 + 参数摘要)。 */
|
|
70
|
+
function toolCallMeta(data) {
|
|
71
|
+
const args = data.args && data.args.summary !== undefined ? ` — ${data.args.summary}` : ''
|
|
72
|
+
return `${data.name}${args}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 工具结果事件摘要(名称 + 成败 + 耗时)。 */
|
|
76
|
+
function toolResultMeta(data) {
|
|
77
|
+
const result = data.ok === false ? strings.toolFail() : strings.toolOk()
|
|
78
|
+
return `${data.name} · ${result} · ${data.ms}ms`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 事件 → 摘要文本(单行,尽力而为)。 */
|
|
82
|
+
function eventMeta(event) {
|
|
83
|
+
const data = event.data || {}
|
|
84
|
+
if (event.type === 'agent_status') return agentMeta(data)
|
|
85
|
+
if (event.type === 'llm_stream') return llmMeta(data)
|
|
86
|
+
if (event.type === 'tool_call') return toolCallMeta(data)
|
|
87
|
+
if (event.type === 'tool_result') return toolResultMeta(data)
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 单条事件行(徽标 + 时间 + 摘要)。 */
|
|
92
|
+
function EventRow({ event }) {
|
|
93
|
+
const meta = eventMeta(event)
|
|
94
|
+
return createElement('div', { className: 'dso-event' },
|
|
95
|
+
createElement('div', { className: 'dso-event-head' },
|
|
96
|
+
createElement('span', { className: `dso-badge dso-badge-${badgeKind(event)}` }, typeLabel(event)),
|
|
97
|
+
createElement('span', { className: 'dso-time' }, timeText(event.time)),
|
|
98
|
+
),
|
|
99
|
+
meta !== '' ? createElement('div', { className: 'dso-event-meta' }, meta) : null,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 类型过滤按钮组。 */
|
|
104
|
+
function TypeFilter({ filter, onFilter }) {
|
|
105
|
+
const options = [
|
|
106
|
+
['', strings.filterAll()],
|
|
107
|
+
['agent_status', strings.filterStatus()],
|
|
108
|
+
['llm_stream', strings.filterLlm()],
|
|
109
|
+
['tool', strings.filterTools()],
|
|
110
|
+
]
|
|
111
|
+
return createElement('div', { className: 'dso-filters' },
|
|
112
|
+
options.map(([value, label]) => createElement('button', {
|
|
113
|
+
key: value,
|
|
114
|
+
className: `dso-chip${filter === value ? ' dso-chip-active' : ''}`,
|
|
115
|
+
onClick: () => onFilter(value),
|
|
116
|
+
}, label)),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 按过滤条件筛选事件(tool = tool_call + tool_result)。 */
|
|
121
|
+
function filterEvents(events, filter) {
|
|
122
|
+
if (filter === '') return events
|
|
123
|
+
return events.filter((event) => filter === 'tool'
|
|
124
|
+
? event.type === 'tool_call' || event.type === 'tool_result'
|
|
125
|
+
: event.type === filter)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 拉取会话列表与事件(选中为空时自动选当前/首个会话)。 */
|
|
129
|
+
async function loadReplayData(selected, currentSession, setters) {
|
|
130
|
+
try {
|
|
131
|
+
const list = await apiJson('/observability/api/sessions')
|
|
132
|
+
setters.setSessions(list)
|
|
133
|
+
if (selected === '' && list.length > 0) {
|
|
134
|
+
const preferred = list.some((s) => s.sessionId === currentSession) ? currentSession : list[0].sessionId
|
|
135
|
+
setters.setSelected(preferred)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const query = selected !== ''
|
|
139
|
+
? `/observability/api/events?sessionId=${encodeURIComponent(selected)}&limit=300`
|
|
140
|
+
: '/observability/api/events?limit=0'
|
|
141
|
+
setters.setEvents(await apiJson(query))
|
|
142
|
+
setters.setError('')
|
|
143
|
+
} catch (err) {
|
|
144
|
+
setters.setError(err instanceof Error ? err.message : String(err))
|
|
145
|
+
} finally {
|
|
146
|
+
setters.setLoading(false)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 工具栏:会话选择 + 类型过滤。 */
|
|
151
|
+
function ReplayToolbar({ sessions, selected, onSelect, filter, onFilter }) {
|
|
152
|
+
return createElement('div', { className: 'dso-toolbar' },
|
|
153
|
+
createElement('select', {
|
|
154
|
+
className: 'dso-select',
|
|
155
|
+
value: selected,
|
|
156
|
+
onChange: (e) => onSelect(e.target.value),
|
|
157
|
+
},
|
|
158
|
+
sessions.length === 0
|
|
159
|
+
? createElement('option', { value: '' }, strings.allSessions())
|
|
160
|
+
: sessions.map((s) => createElement('option', { key: s.sessionId, value: s.sessionId }, s.sessionId)),
|
|
161
|
+
),
|
|
162
|
+
createElement(TypeFilter, { filter, onFilter }),
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 轨迹回放主面板:会话选择 + 类型过滤 + 时间轴(可见时轮询)。 */
|
|
167
|
+
function ReplayPanel(props) {
|
|
168
|
+
const currentSession = props.scope?.sessionId || ''
|
|
169
|
+
const visible = props.visible !== false
|
|
170
|
+
const [sessions, setSessions] = useState([])
|
|
171
|
+
const [selected, setSelected] = useState('')
|
|
172
|
+
const [filter, setFilter] = useState('')
|
|
173
|
+
const [events, setEvents] = useState([])
|
|
174
|
+
const [loading, setLoading] = useState(true)
|
|
175
|
+
const [error, setError] = useState('')
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!visible) return undefined
|
|
179
|
+
let alive = true
|
|
180
|
+
const setters = { setSessions, setSelected, setEvents, setError, setLoading }
|
|
181
|
+
const tick = () => { if (alive) void loadReplayData(selected, currentSession, setters) }
|
|
182
|
+
tick()
|
|
183
|
+
const timer = setInterval(tick, REPLAY_POLL_MS)
|
|
184
|
+
return () => { alive = false; clearInterval(timer) }
|
|
185
|
+
}, [visible, selected, currentSession])
|
|
186
|
+
|
|
187
|
+
const filtered = filterEvents(events, filter)
|
|
188
|
+
const rows = filtered.map((event, index) => createElement(EventRow, { key: event.id ?? index, event }))
|
|
189
|
+
|
|
190
|
+
return createElement('div', { className: 'dso-panel' },
|
|
191
|
+
createElement(ReplayToolbar, { sessions, selected, onSelect: setSelected, filter, onFilter: setFilter }),
|
|
192
|
+
error !== '' ? createElement('div', { className: 'dso-empty' }, `${strings.loadError()}:${error}`) : null,
|
|
193
|
+
loading && error === '' ? createElement('div', { className: 'dso-empty' }, strings.loading()) : null,
|
|
194
|
+
!loading && error === '' && filtered.length === 0
|
|
195
|
+
? createElement('div', { className: 'dso-empty' }, strings.emptyEvents())
|
|
196
|
+
: null,
|
|
197
|
+
createElement('div', { className: 'dso-timeline' }, rows),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ── 样式(DSH 语义 token,随 activation 注入 / teardown 卸载)──────
|
|
2
|
+
const STYLES = `
|
|
3
|
+
.dso-panel{display:flex;flex-direction:column;gap:10px;padding:12px;color:var(--dsw-alias-label-primary)}
|
|
4
|
+
.dso-toolbar{display:flex;flex-direction:column;gap:8px}
|
|
5
|
+
.dso-select{flex:1;min-width:0;font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-primary);
|
|
6
|
+
background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px}
|
|
7
|
+
.dso-input{flex:1;min-width:0;font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-primary);
|
|
8
|
+
background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px}
|
|
9
|
+
.dso-input::placeholder{color:var(--dsw-alias-label-tertiary)}
|
|
10
|
+
.dso-repo-row{display:flex;gap:8px;align-items:center}
|
|
11
|
+
.dso-repo-input{flex:1}
|
|
12
|
+
.dso-filters{display:flex;gap:6px;flex-wrap:wrap}
|
|
13
|
+
.dso-chip{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);background:transparent;
|
|
14
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;cursor:pointer}
|
|
15
|
+
.dso-chip-active{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-interactive-primary);
|
|
16
|
+
background:color-mix(in srgb, var(--dsw-alias-interactive-primary) 12%, transparent)}
|
|
17
|
+
.dso-timeline{display:flex;flex-direction:column;gap:6px;max-height:calc(100vh - 240px);overflow-y:auto}
|
|
18
|
+
.dso-event{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 10px}
|
|
19
|
+
.dso-event-head{display:flex;align-items:center;gap:8px;justify-content:space-between}
|
|
20
|
+
.dso-badge{flex:none;font:var(--dsw-font-xxxs-strong-11);border-radius:4px;padding:1px 6px}
|
|
21
|
+
.dso-badge-status{color:var(--dsw-alias-state-info-primary);background:color-mix(in srgb, var(--dsw-alias-state-info-primary) 14%, transparent)}
|
|
22
|
+
.dso-badge-llm{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 14%, transparent)}
|
|
23
|
+
.dso-badge-tool{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent)}
|
|
24
|
+
.dso-time{font:var(--dsw-font-xxxs-11);color:var(--dsw-alias-label-tertiary)}
|
|
25
|
+
.dso-event-meta{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);line-height:1.6;word-break:break-word;margin-top:2px}
|
|
26
|
+
.dso-empty{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-tertiary);text-align:center;padding:16px 8px;line-height:1.7}
|
|
27
|
+
.dso-status{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-label-secondary)}
|
|
28
|
+
.dso-actions{display:flex;gap:8px;flex-wrap:wrap}
|
|
29
|
+
.dso-btn{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);
|
|
30
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 12px;cursor:pointer}
|
|
31
|
+
.dso-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
32
|
+
.dso-btn:disabled{opacity:.5;cursor:default}
|
|
33
|
+
.dso-btn-primary{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-interactive-primary);
|
|
34
|
+
background:color-mix(in srgb, var(--dsw-alias-interactive-primary) 16%, transparent)}
|
|
35
|
+
.dso-section{display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px}
|
|
36
|
+
.dso-section-title{font:var(--dsw-font-xs-strong-13);color:var(--dsw-alias-label-primary)}
|
|
37
|
+
.dso-diff{max-height:240px;overflow:auto;font:var(--dsw-font-mono-xxs);font-size:11px;line-height:1.5;
|
|
38
|
+
color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);
|
|
39
|
+
border-radius:6px;padding:8px;white-space:pre-wrap;word-break:break-all}
|
|
40
|
+
.dso-form{display:flex;flex-direction:column;gap:6px}
|
|
41
|
+
.dso-type{flex:none;width:96px}
|
|
42
|
+
.dso-textarea{min-height:52px;resize:vertical;font:var(--dsw-font-xxs-12)}
|
|
43
|
+
.dso-feedback{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);word-break:break-all;line-height:1.5}
|
|
44
|
+
.dso-issue{display:flex;flex-direction:column;gap:2px;border-radius:6px;padding:6px 8px;font:var(--dsw-font-xxs-12)}
|
|
45
|
+
.dso-issue-error{background:color-mix(in srgb, var(--dsw-alias-state-danger-primary) 12%, transparent)}
|
|
46
|
+
.dso-issue-warning{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent)}
|
|
47
|
+
.dso-issue-info{background:color-mix(in srgb, var(--dsw-alias-state-info-primary) 10%, transparent)}
|
|
48
|
+
.dso-issue-sev{font:var(--dsw-font-xxxs-strong-11);text-transform:uppercase}
|
|
49
|
+
.dso-issue-error .dso-issue-sev{color:var(--dsw-alias-state-danger-primary)}
|
|
50
|
+
.dso-issue-warning .dso-issue-sev{color:var(--dsw-alias-state-warn-primary)}
|
|
51
|
+
.dso-issue-info .dso-issue-sev{color:var(--dsw-alias-state-info-primary)}
|
|
52
|
+
.dso-issue-rule{font:var(--dsw-font-mono-xxs);font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
53
|
+
.dso-issue-msg{color:var(--dsw-alias-label-primary);line-height:1.5}
|
|
54
|
+
.dso-review-ok{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-state-success-primary)}
|
|
55
|
+
.dso-ai{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-secondary);line-height:1.5;
|
|
56
|
+
border:1px dashed var(--dsw-alias-border-l2);border-radius:6px;padding:6px 8px}
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
function injectStyles() {
|
|
60
|
+
if (typeof document === 'undefined' || typeof document.head === 'undefined') return () => {}
|
|
61
|
+
const style = document.createElement('style')
|
|
62
|
+
style.setAttribute('data-dsh-my-observability', 'styles')
|
|
63
|
+
style.textContent = STYLES
|
|
64
|
+
document.head.appendChild(style)
|
|
65
|
+
return () => {
|
|
66
|
+
if (style.parentNode !== null) style.parentNode.removeChild(style)
|
|
67
|
+
}
|
|
68
|
+
}
|
package/lib/review.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-observability — incremental diff review (rule engine).
|
|
3
|
+
*
|
|
4
|
+
* 提交前增量 diff 审查:确定性规则引擎(纯函数,可独立测试),检查:
|
|
5
|
+
* - debug-statement 调试残留(console.* / debugger / print 族)→ warning
|
|
6
|
+
* - secret-leak 密钥/凭据硬编码(password/api_key/secret/token)→ error
|
|
7
|
+
* - conflict-marker 合并冲突标记残留(<<<<<<< / ======= / >>>>>>>)→ error
|
|
8
|
+
* - todo-marker TODO/FIXME/HACK 标记 → info
|
|
9
|
+
* - trailing-space 新增行尾随空格 → info
|
|
10
|
+
* - large-diff 单文件变更超阈值 → warning
|
|
11
|
+
* - binary-file 二进制文件变更 → warning
|
|
12
|
+
* - no-test-change 有源码变更但无测试变更 → info
|
|
13
|
+
*
|
|
14
|
+
* AI 增强审查见 lib/ai.js(可选,agents 服务可用时追加 LLM 结论)。
|
|
15
|
+
*/
|
|
16
|
+
import { isTestFile, isSourceFile } from './diff.js'
|
|
17
|
+
|
|
18
|
+
/** 单文件变更行数阈值(large-diff 规则)。 */
|
|
19
|
+
export const LARGE_DIFF_LINES = 300
|
|
20
|
+
|
|
21
|
+
/** 对已解析 diff 运行规则引擎,返回 { summary, issues }。 */
|
|
22
|
+
export function reviewRules(parsed) {
|
|
23
|
+
const issues = []
|
|
24
|
+
for (const file of parsed.files) {
|
|
25
|
+
for (const added of file.addedLines) {
|
|
26
|
+
pushIf(issues, debugIssue(file, added))
|
|
27
|
+
pushIf(issues, secretIssue(file, added))
|
|
28
|
+
pushIf(issues, conflictIssue(file, added))
|
|
29
|
+
pushIf(issues, todoIssue(file, added))
|
|
30
|
+
pushIf(issues, trailingIssue(file, added))
|
|
31
|
+
}
|
|
32
|
+
pushIf(issues, largeDiffIssue(file))
|
|
33
|
+
pushIf(issues, binaryIssue(file))
|
|
34
|
+
}
|
|
35
|
+
pushIf(issues, noTestIssue(parsed))
|
|
36
|
+
return summarize(parsed, issues)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 汇总:变更统计 + 问题分级计数。 */
|
|
40
|
+
function summarize(parsed, issues) {
|
|
41
|
+
const files = parsed.files
|
|
42
|
+
const insertions = files.reduce((sum, file) => sum + file.insertions, 0)
|
|
43
|
+
const deletions = files.reduce((sum, file) => sum + file.deletions, 0)
|
|
44
|
+
return {
|
|
45
|
+
issues,
|
|
46
|
+
summary: {
|
|
47
|
+
files: files.length,
|
|
48
|
+
insertions,
|
|
49
|
+
deletions,
|
|
50
|
+
issues: issues.length,
|
|
51
|
+
errors: countBy(issues, 'error'),
|
|
52
|
+
warnings: countBy(issues, 'warning'),
|
|
53
|
+
infos: countBy(issues, 'info'),
|
|
54
|
+
binary: parsed.binary,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function countBy(issues, severity) {
|
|
60
|
+
return issues.filter((issue) => issue.severity === severity).length
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pushIf(issues, issue) {
|
|
64
|
+
if (issue !== undefined) issues.push(issue)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 调试残留(console.* / debugger / print 族)。 */
|
|
68
|
+
function debugIssue(file, added) {
|
|
69
|
+
if (!/console\.(log|debug|warn|info|error)|debugger\b|\bprint\(|println\(|System\.out\.print/.test(added.text)) return undefined
|
|
70
|
+
return issue('warning', 'debug-statement', file, added, '调试残留语句(console/print/debugger)')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 密钥/凭据硬编码。 */
|
|
74
|
+
function secretIssue(file, added) {
|
|
75
|
+
if (!/(password|passwd|api[_-]?key|secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"][^'"]{4,}['"]/i.test(added.text)) return undefined
|
|
76
|
+
return issue('error', 'secret-leak', file, added, '疑似硬编码密钥/凭据')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 合并冲突标记残留。 */
|
|
80
|
+
function conflictIssue(file, added) {
|
|
81
|
+
if (!/^(<<<<<<<|=======|>>>>>>>)/.test(added.text)) return undefined
|
|
82
|
+
return issue('error', 'conflict-marker', file, added, '合并冲突标记残留')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** TODO/FIXME 标记。 */
|
|
86
|
+
function todoIssue(file, added) {
|
|
87
|
+
if (!/\b(TODO|FIXME|HACK|XXX)\b/.test(added.text)) return undefined
|
|
88
|
+
return issue('info', 'todo-marker', file, added, 'TODO/FIXME 标记')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 尾随空格。 */
|
|
92
|
+
function trailingIssue(file, added) {
|
|
93
|
+
if (!/\s+$/.test(added.text)) return undefined
|
|
94
|
+
return issue('info', 'trailing-space', file, added, '行尾多余空格')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 单文件变更超阈值。 */
|
|
98
|
+
function largeDiffIssue(file) {
|
|
99
|
+
const changed = file.insertions + file.deletions
|
|
100
|
+
if (changed <= LARGE_DIFF_LINES || file.binary) return undefined
|
|
101
|
+
return issue('warning', 'large-diff', file, null, `单文件变更 ${changed} 行(>${LARGE_DIFF_LINES}),建议拆分提交`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 二进制文件变更。 */
|
|
105
|
+
function binaryIssue(file) {
|
|
106
|
+
if (!file.binary) return undefined
|
|
107
|
+
return issue('warning', 'binary-file', file, null, '二进制文件变更')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 有源码变更但无测试变更。 */
|
|
111
|
+
function noTestIssue(parsed) {
|
|
112
|
+
const sourceFiles = parsed.files.filter((file) => isSourceFile(file.path))
|
|
113
|
+
const testFiles = parsed.files.filter((file) => isTestFile(file.path))
|
|
114
|
+
if (sourceFiles.length === 0 || testFiles.length > 0) return undefined
|
|
115
|
+
return issue('info', 'no-test-change', null, null, '有源码变更但没有测试文件变更')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function issue(severity, rule, file, added, message) {
|
|
119
|
+
return {
|
|
120
|
+
severity,
|
|
121
|
+
rule,
|
|
122
|
+
file: file?.path ?? '',
|
|
123
|
+
line: added?.line ?? 0,
|
|
124
|
+
message,
|
|
125
|
+
}
|
|
126
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-my-observability — /observability/api routes.
|
|
3
|
+
*
|
|
4
|
+
* 所有请求先做 loopback 信任围栏(与 /api 网关一致的契约)。方法分派:
|
|
5
|
+
* - GET /sessions — 有审计事件的会话列表
|
|
6
|
+
* - GET /events?sessionId&type&limit — 会话事件(时间轴正序)
|
|
7
|
+
* - GET /status — 审计统计 + 功能开关
|
|
8
|
+
* - GET /git/status?repo= — 仓库状态(分支 + 变更)
|
|
9
|
+
* - GET /git/diff?repo=&staged= — 差异文本
|
|
10
|
+
* - POST /git/commit — 类型化提交(Conventional Commits)
|
|
11
|
+
* - POST /review — 增量 diff 审查(规则引擎 + 可选 AI)
|
|
12
|
+
*/
|
|
13
|
+
import { isTrustedApiRequest } from './fence.js'
|
|
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
|
+
|
|
19
|
+
/** 注册 /observability/api 路由(effect 持有 disposer)。 */
|
|
20
|
+
export function registerObservabilityRoutes(ctx, store, options) {
|
|
21
|
+
const webRuntime = ctx.get ? ctx.get('webRuntime') : undefined
|
|
22
|
+
const trustedHosts = webRuntime !== undefined && webRuntime !== null && Array.isArray(webRuntime.trustedHosts)
|
|
23
|
+
? webRuntime.trustedHosts
|
|
24
|
+
: []
|
|
25
|
+
const fence = (request) => isTrustedApiRequest(request, trustedHosts)
|
|
26
|
+
|
|
27
|
+
ctx.effect(() => ctx.webServer.register({
|
|
28
|
+
kind: 'prefix',
|
|
29
|
+
path: '/observability/api',
|
|
30
|
+
handler: apiHandler(ctx, fence, store, options),
|
|
31
|
+
}), 'dsh-my-observability: /observability/api routes')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 统一 handler:fence → 方法分派 → 404/错误兜底。 */
|
|
35
|
+
function apiHandler(ctx, fence, store, options) {
|
|
36
|
+
return async (request, response) => {
|
|
37
|
+
if (!fence(request)) {
|
|
38
|
+
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const url = new URL(request.url ?? '/', 'http://dsh.internal')
|
|
42
|
+
const pathname = url.pathname
|
|
43
|
+
const method = pathname.startsWith('/observability/api/') ? pathname.slice('/observability/api/'.length) : undefined
|
|
44
|
+
try {
|
|
45
|
+
const handled = await dispatchMethod(method, request, response, url, ctx, store, options)
|
|
46
|
+
if (!handled) {
|
|
47
|
+
writeJson(response, 404, { ok: false, error: { message: 'unknown dsh-my-observability API method' } })
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
writeError(response, error)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 方法 + 请求动词匹配。 */
|
|
56
|
+
function isMethod(method, request, name, verb) {
|
|
57
|
+
return method === name && request.method === verb
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 按 method 分派到具体 handler;未识别返回 false(调用方回 404)。 */
|
|
61
|
+
async function dispatchMethod(method, request, response, url, ctx, store, options) {
|
|
62
|
+
if (isMethod(method, request, 'sessions', 'GET')) {
|
|
63
|
+
writeJson(response, 200, { ok: true, value: store.sessions() })
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
if (isMethod(method, request, 'events', 'GET')) {
|
|
67
|
+
writeJson(response, 200, { ok: true, value: store.events(queryOf(url, 'sessionId'), queryOf(url, 'type'), limitOf(url)) })
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
if (isMethod(method, request, 'status', 'GET')) {
|
|
71
|
+
writeJson(response, 200, { ok: true, value: statusValue(store, options) })
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
if (isMethod(method, request, 'git/status', 'GET')) {
|
|
75
|
+
await handleGitStatus(response, repoOf(url))
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
if (isMethod(method, request, 'git/diff', 'GET')) {
|
|
79
|
+
await handleGitDiff(response, repoOf(url), url.searchParams.get('staged') === '1')
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
if (isMethod(method, request, 'git/commit', 'POST')) {
|
|
83
|
+
await handleGitCommit(request, response)
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
if (isMethod(method, request, 'review', 'POST')) {
|
|
87
|
+
await handleReview(ctx, request, response, options)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── handlers ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** 状态:审计统计 + 功能开关(aiReview 只暴露开关)。 */
|
|
96
|
+
function statusValue(store, options) {
|
|
97
|
+
return {
|
|
98
|
+
auditCount: store.count(),
|
|
99
|
+
sessions: store.sessions().length,
|
|
100
|
+
aiReview: options.aiReview !== false,
|
|
101
|
+
gitEnabled: true,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** git status:非仓库路径 400。 */
|
|
106
|
+
async function handleGitStatus(response, repoPath) {
|
|
107
|
+
if (repoPath === '') {
|
|
108
|
+
writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } })
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const result = await gitStatus(repoPath)
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
writeJson(response, 400, { ok: false, error: result.error })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
writeJson(response, 200, { ok: true, value: result })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** git diff:非仓库路径 400。 */
|
|
120
|
+
async function handleGitDiff(response, repoPath, staged) {
|
|
121
|
+
if (repoPath === '') {
|
|
122
|
+
writeJson(response, 400, { ok: false, error: { message: 'repo query param required' } })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const result = await gitDiff(repoPath, staged)
|
|
126
|
+
if (!result.ok) {
|
|
127
|
+
writeJson(response, 400, { ok: false, error: result.error })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
writeJson(response, 200, { ok: true, value: { text: result.text } })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 类型化提交:body { repoPath, type, scope, description, body }。 */
|
|
134
|
+
async function handleGitCommit(request, response) {
|
|
135
|
+
const payload = await readJsonBody(request)
|
|
136
|
+
const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : ''
|
|
137
|
+
if (repoPath === '') {
|
|
138
|
+
writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const result = await gitCommit(repoPath, payload)
|
|
142
|
+
if (!result.ok) {
|
|
143
|
+
writeJson(response, 400, { ok: false, error: result.error })
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
writeJson(response, 200, {
|
|
147
|
+
ok: true,
|
|
148
|
+
value: { hash: result.hash, message: result.message, summary: result.summary },
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 增量 diff 审查:规则引擎 + 可选 AI 增强(失败不影响规则结果)。 */
|
|
153
|
+
async function handleReview(ctx, request, response, options) {
|
|
154
|
+
const payload = await readJsonBody(request)
|
|
155
|
+
const repoPath = typeof payload.repoPath === 'string' ? payload.repoPath : ''
|
|
156
|
+
if (repoPath === '') {
|
|
157
|
+
writeJson(response, 400, { ok: false, error: { message: 'repoPath required' } })
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const staged = payload.staged === true
|
|
161
|
+
const diffResult = await gitDiff(repoPath, staged)
|
|
162
|
+
if (!diffResult.ok) {
|
|
163
|
+
writeJson(response, 400, { ok: false, error: diffResult.error })
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const report = reviewRules(parseDiff(diffResult.text))
|
|
167
|
+
report.ai = await aiOutcome(ctx, payload, options, diffResult.text, report)
|
|
168
|
+
writeJson(response, 200, { ok: true, value: report })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** AI 审查开关判定:配置开启 + 请求未显式关闭 → 运行(失败降级)。 */
|
|
172
|
+
async function aiOutcome(ctx, payload, options, diffText, report) {
|
|
173
|
+
if (options.aiReview === false) return { enabled: false }
|
|
174
|
+
if (payload.aiReview === false) return { enabled: false }
|
|
175
|
+
return runAiReview(ctx, diffText, report, options.aiTimeoutMs)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── HTTP helpers ───────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function queryOf(url, name) {
|
|
181
|
+
return url.searchParams.get(name) ?? ''
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function repoOf(url) {
|
|
185
|
+
const repo = url.searchParams.get('repo')
|
|
186
|
+
return typeof repo === 'string' ? repo : ''
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function limitOf(url) {
|
|
190
|
+
const raw = url.searchParams.get('limit')
|
|
191
|
+
const parsed = raw === null ? 0 : Number(raw)
|
|
192
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Read a JSON request body (bounded). */
|
|
196
|
+
async function readJsonBody(request) {
|
|
197
|
+
let body = ''
|
|
198
|
+
for await (const chunk of request) {
|
|
199
|
+
body += chunk
|
|
200
|
+
if (body.length > 1_000_000) throw new Error('request body too large')
|
|
201
|
+
}
|
|
202
|
+
if (body === '') return {}
|
|
203
|
+
return JSON.parse(body)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function writeJson(response, status, value) {
|
|
207
|
+
const payload = JSON.stringify(value)
|
|
208
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-cache' })
|
|
209
|
+
response.end(payload)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function writeError(response, error) {
|
|
213
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
214
|
+
writeJson(response, 400, { ok: false, error: { message } })
|
|
215
|
+
}
|