@dsh-fly/dsh-plugin-fly-engagement 0.1.4 → 0.2.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/README.md +13 -4
- package/index.js +115 -34
- package/lib/client.js +365 -81
- package/lib/fold.mjs +119 -37
- package/package.json +1 -1
- package/tools/index.js +89 -15
package/README.md
CHANGED
|
@@ -4,13 +4,22 @@ DSH Fly 的 **engagement 记录面**(Security / CTF 作战室的"把发现沉
|
|
|
4
4
|
|
|
5
5
|
- **agent 平面**(`tools/index.js`,由 Fly preset 按会话挂载):`eng_record`(goal/facts/findings/assets/edges,
|
|
6
6
|
漏洞**必须带复现步骤**)、`eng_state`(投影 + `counts`/`dropped`/`refused`/`truncated`)、
|
|
7
|
-
`eng_report
|
|
8
|
-
- **宿主平面**(根 `index.js`,`inject: ['webServer','shell']`):`GET /fly/engagement/state|report
|
|
9
|
-
|
|
7
|
+
`eng_report`(Markdown 报告)、`eng_adopt`(认领 v1 未归属记录)。
|
|
8
|
+
- **宿主平面**(根 `index.js`,`inject: ['webServer','shell']`):`GET /fly/engagement/state|report`、
|
|
9
|
+
`POST /fly/engagement/adopt`,给抽屉面板取数与认领;两半共用 `lib/fold.mjs` 的投影口径
|
|
10
|
+
⇒ 面板读数与 `eng_state` 永远同口径。
|
|
10
11
|
|
|
11
12
|
存储:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影 = fold)。
|
|
12
13
|
**不写任何自定义会话事件**(DSH 毒药条);文件读写全走 `ctx.shell` 接缝。
|
|
13
14
|
|
|
15
|
+
## v2:会话维度
|
|
16
|
+
|
|
17
|
+
一个工作区一条账本,**每行带 `session` 归属**——同一工作区开多个会话各自一条证据链
|
|
18
|
+
(同 workspace 分析不同程序、同 repo 分析不同模块都不会混)。默认只看本会话,
|
|
19
|
+
`scope=workspace` 看全部(`workspaceCounts` 始终给全量计数);goal 按会话独立。
|
|
20
|
+
边(edges)**跟随端点**:两端在作用域内即可见。v1 遗留记录(无 session)计入 `legacy`,
|
|
21
|
+
经 `eng_adopt` 认领进当前会话(已归属他人的一律跳过,`skipped` 计数)。
|
|
22
|
+
|
|
14
23
|
借鉴 `@howmp/dsh-pentest`(MIT)的数据形状(六表 + 带词汇的边 + 必填步骤 + 双计数 + 确定性 id),
|
|
15
24
|
承载面用 DSH Fly 自己的抽屉面板。设计与验收见 dsh-fly 仓 `docs/security-mode-review-and-plan.md` §11。
|
|
16
25
|
|
|
@@ -19,7 +28,7 @@ DSH Fly 的 **engagement 记录面**(Security / CTF 作战室的"把发现沉
|
|
|
19
28
|
| 挂载点 | 文件 | 干什么 |
|
|
20
29
|
| --- | --- | --- |
|
|
21
30
|
| 宿主行(bundle patch,**裸包名**) | `index.js` | 端点 + 客户端半边(客户端半边要求行名是裸包名,带 `/` 不会被登记) |
|
|
22
|
-
| agent 行(preset 指向子路径) | `tools/index.js` |
|
|
31
|
+
| agent 行(preset 指向子路径) | `tools/index.js` | 四个 `eng_*` 工具 |
|
|
23
32
|
|
|
24
33
|
## 门禁
|
|
25
34
|
|
package/index.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { foldEvents, ledgerPath, renderReport,
|
|
1
|
+
import { foldEvents, ledgerPath, renderReport, scopeView } from './lib/fold.mjs'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* fly-engagement —— **宿主平面的面板行**(根文件 = 裸包名,客户端半边才登记得上)。
|
|
5
5
|
*
|
|
6
6
|
* 形状照 `dsh-plugin-fly-smart`(一个包、两种挂载):
|
|
7
|
-
* · 根 `index.js`(本文件)= 宿主行:`inject: ['webServer', 'shell']
|
|
8
|
-
* `GET /fly/engagement/state|report
|
|
9
|
-
* · `tools/index.js` = agent
|
|
7
|
+
* · 根 `index.js`(本文件)= 宿主行:`inject: ['webServer', 'shell']`,做
|
|
8
|
+
* `GET /fly/engagement/state|report` 与 `POST /fly/engagement/adopt`;
|
|
9
|
+
* · `tools/index.js` = agent 平面的 `eng_*`,由 preset 按会话挂;
|
|
10
10
|
* · 两半共用 `lib/fold.mjs` 的投影口径 ⇒ 面板读数与 `eng_state` 永远同口径。
|
|
11
11
|
*
|
|
12
|
-
* 数据来源:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影=fold
|
|
12
|
+
* 数据来源:`<workspace>/.fly-engagement/events.jsonl`(append-only,投影=fold)。
|
|
13
|
+
* 一个工作区一条账本,**每行带 session 归属**;默认只看本会话,`scope=workspace` 看全部。
|
|
13
14
|
* 读文件走沙箱化的 `ctx.shell` 接缝(本包不 import `node:fs`)。
|
|
14
15
|
*/
|
|
15
16
|
|
|
@@ -33,6 +34,18 @@ async function readText(shell, file) {
|
|
|
33
34
|
return result.stdout.text
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
async function appendText(shell, file, text) {
|
|
38
|
+
const encoded = Buffer.from(text, 'utf8').toString('base64')
|
|
39
|
+
const handle = await shell.execute(shell.resolve({ command: 'printf %s ' + shq(encoded) + ' | base64 -d >> ' + shq(file) }))
|
|
40
|
+
const result = await handle.result()
|
|
41
|
+
if (result.sandbox?.denied) {
|
|
42
|
+
const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
|
43
|
+
error.name = 'SandboxError'
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
return result
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
function json(res, status, payload) {
|
|
37
50
|
const body = JSON.stringify(payload, null, 2)
|
|
38
51
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
|
|
@@ -41,12 +54,19 @@ function json(res, status, payload) {
|
|
|
41
54
|
|
|
42
55
|
/** 每条路由都自述(面板的"这个面板能问什么"表,与 netops 同一条纪律)。 */
|
|
43
56
|
export const endpoints = [
|
|
44
|
-
{ path: `${BASE}/state`, summary: 'engagement 投影(与 eng_state
|
|
45
|
-
{ path: `${BASE}/report`, summary: '
|
|
57
|
+
{ path: `${BASE}/state`, summary: 'engagement 投影(与 eng_state 同口径):?workspace=&session=&scope=session|workspace&window=' },
|
|
58
|
+
{ path: `${BASE}/report`, summary: 'Markdown 报告(与 eng_report 同一份渲染):?workspace=&session=&scope=' },
|
|
59
|
+
{ path: `${BASE}/adopt`, method: 'POST', summary: '认领未归属(v1)记录到当前会话:{ workspace, session, all? , ids?, goal? }' },
|
|
46
60
|
]
|
|
47
61
|
|
|
62
|
+
function badWorkspace(workspace) {
|
|
63
|
+
if (typeof workspace !== 'string' || workspace.trim() === '') return 'missing ?workspace=<absolute path>'
|
|
64
|
+
if (!workspace.startsWith('/')) return 'workspace 必须是绝对路径(收到 ' + workspace + ')'
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
48
68
|
/**
|
|
49
|
-
*
|
|
69
|
+
* 处理一条 GET。`?workspace=<绝对路径>` 必填;`session` 缺省 = 未归属(v1 遗留)。
|
|
50
70
|
* 返回 `{ status, payload }`,由 transport 层写回 —— 纯函数,好测。
|
|
51
71
|
*/
|
|
52
72
|
export async function answer(shell, url) {
|
|
@@ -54,48 +74,109 @@ export async function answer(shell, url) {
|
|
|
54
74
|
if (!path.startsWith(BASE)) return { status: 404, payload: { error: 'not found', endpoints } }
|
|
55
75
|
const query = new URLSearchParams(path.slice(path.indexOf('?') + 1))
|
|
56
76
|
const workspace = query.get('workspace')
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return { status: 400, payload: { error: 'workspace 必须是绝对路径(收到 ' + workspace + ')', endpoints } }
|
|
62
|
-
}
|
|
77
|
+
const invalid = badWorkspace(workspace)
|
|
78
|
+
if (invalid !== null) return { status: 400, payload: { error: invalid, endpoints } }
|
|
79
|
+
const session = (query.get('session') ?? '').trim() === '' ? null : query.get('session')
|
|
80
|
+
const scope = query.get('scope') === 'workspace' ? 'workspace' : 'session'
|
|
63
81
|
const windowSize = Math.min(2000, Math.max(1, Math.round(Number(query.get('window') ?? 200) || 200)))
|
|
64
82
|
const file = ledgerPath(workspace)
|
|
65
83
|
const state = foldEvents((await readText(shell, file)).split('\n'))
|
|
84
|
+
const view = scopeView(state, { session, scope }, windowSize)
|
|
66
85
|
if (path.startsWith(BASE + '/report')) {
|
|
67
|
-
return { status: 200, payload: { workspace, ledger: file, markdown: renderReport(workspace,
|
|
86
|
+
return { status: 200, payload: { workspace, ledger: file, session, scope, markdown: renderReport(workspace, view) } }
|
|
68
87
|
}
|
|
69
88
|
if (path.startsWith(BASE + '/state')) {
|
|
70
89
|
return {
|
|
71
90
|
status: 200,
|
|
72
|
-
payload: {
|
|
73
|
-
workspace,
|
|
74
|
-
ledger: file,
|
|
75
|
-
window: windowSize,
|
|
76
|
-
projection: windowView(state, windowSize),
|
|
77
|
-
endpoints,
|
|
78
|
-
},
|
|
91
|
+
payload: { workspace, ledger: file, window: windowSize, session, scope, projection: view, endpoints },
|
|
79
92
|
}
|
|
80
93
|
}
|
|
81
94
|
return { status: 404, payload: { error: 'not found', endpoints } }
|
|
82
95
|
}
|
|
83
96
|
|
|
97
|
+
/**
|
|
98
|
+
* `POST /fly/engagement/adopt` —— 面板上的「认领未归属记录」按钮走这里。
|
|
99
|
+
* 只改未归属(session=null)的行;已归属其他会话的一律跳过。
|
|
100
|
+
*/
|
|
101
|
+
export async function adopt(shell, body) {
|
|
102
|
+
const workspace = body?.workspace
|
|
103
|
+
const invalid = badWorkspace(workspace)
|
|
104
|
+
if (invalid !== null) return { status: 400, payload: { error: invalid, endpoints } }
|
|
105
|
+
const session = typeof body?.session === 'string' ? body.session.trim() : ''
|
|
106
|
+
if (session === '') return { status: 400, payload: { error: 'adopt 需要 session(当前会话身份)', endpoints } }
|
|
107
|
+
const file = ledgerPath(workspace)
|
|
108
|
+
const state = foldEvents((await readText(shell, file)).split('\n'))
|
|
109
|
+
const unowned = [...state.facts, ...state.findings, ...state.assets].filter(row => (row.session ?? null) === null)
|
|
110
|
+
const ids = body?.all === true ? unowned.map(row => row.id) : (Array.isArray(body?.ids) ? body.ids : [])
|
|
111
|
+
const goal = body?.goal === true || body?.all === true
|
|
112
|
+
if (ids.length === 0 && goal === false) {
|
|
113
|
+
return { status: 400, payload: { error: '没有可认领的记录(未归属为空,且未请求 goal)', endpoints } }
|
|
114
|
+
}
|
|
115
|
+
const event = { op: 'adopt', at: new Date().toISOString(), session, ids, goal }
|
|
116
|
+
await appendText(shell, file, JSON.stringify(event) + '\n')
|
|
117
|
+
const after = foldEvents((await readText(shell, file)).split('\n'))
|
|
118
|
+
const view = scopeView(after, { session, scope: 'session' }, 2000)
|
|
119
|
+
const count = rows => rows.filter(row => (row.session ?? null) === null).length
|
|
120
|
+
return {
|
|
121
|
+
status: 200,
|
|
122
|
+
payload: {
|
|
123
|
+
workspace,
|
|
124
|
+
session,
|
|
125
|
+
adopted: {
|
|
126
|
+
facts: count(state.facts) - count(after.facts),
|
|
127
|
+
findings: count(state.findings) - count(after.findings),
|
|
128
|
+
assets: count(state.assets) - count(after.assets),
|
|
129
|
+
edges: count(state.edges) - count(after.edges),
|
|
130
|
+
goal: state.goals.filter(row => row.session === null).length - after.goals.filter(row => row.session === null).length,
|
|
131
|
+
},
|
|
132
|
+
legacy: view.legacy,
|
|
133
|
+
skipped: after.skipped,
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function readBody(req) {
|
|
139
|
+
const chunks = []
|
|
140
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
141
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
142
|
+
return text === '' ? {} : JSON.parse(text)
|
|
143
|
+
}
|
|
144
|
+
|
|
84
145
|
export function apply(ctx) {
|
|
85
146
|
const shell = ctx.shell
|
|
86
147
|
ctx.effect(
|
|
87
|
-
() =>
|
|
88
|
-
kind
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
148
|
+
() => {
|
|
149
|
+
// `webServer.register` 对重复的 (kind, path) 是**抛错**语义(组合层契约)。
|
|
150
|
+
// profile 热重载时若上一代路由还没被 dispose,新实例会撞上它 —— 这里把失败
|
|
151
|
+
// 写进宿主日志再抛,否则症状只是"面板一直 404"而没有任何线索。
|
|
152
|
+
try {
|
|
153
|
+
return ctx.webServer.register({
|
|
154
|
+
kind: 'prefix',
|
|
155
|
+
path: BASE,
|
|
156
|
+
handler: async (req, res) => {
|
|
157
|
+
try {
|
|
158
|
+
const url = req.url ?? '/'
|
|
159
|
+
if (req.method === 'POST' && String(url).startsWith(BASE + '/adopt')) {
|
|
160
|
+
const { status, payload } = await adopt(shell, await readBody(req))
|
|
161
|
+
json(res, status, payload)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (req.method !== 'GET') {
|
|
165
|
+
json(res, 405, { error: 'method not allowed', endpoints })
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
const { status, payload } = await answer(shell, url)
|
|
169
|
+
json(res, status, payload)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
json(res, 500, { error: String(error?.message ?? error), endpoints })
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
} catch (error) {
|
|
176
|
+
ctx.logger?.error?.('fly-engagement: 注册 ' + BASE + '/* 失败(面板将 404)', error)
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
},
|
|
99
180
|
'fly-engagement: ' + BASE + '/*',
|
|
100
181
|
)
|
|
101
182
|
}
|
package/lib/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* @dsh-fly/dsh-plugin-fly-engagement — browser half, built by scripts/build-plugin-client.mjs.
|
|
2
|
-
* src-hash:
|
|
2
|
+
* src-hash: 8df6cd805bb668cf7c43bc05afeae736d5f7f1e8e672ac1f4d084ca644afea08
|
|
3
3
|
* Factory-form CJS: the loader calls the factory with its platform `require`
|
|
4
4
|
* and memoizes the returned exports. Keep the banner so the loader id and the
|
|
5
5
|
* package that produced the bundle stay greppable in a live page. */
|
|
@@ -31,36 +31,25 @@ const PANEL_CSS = `/*
|
|
|
31
31
|
* 实现时一律 'var(--dsw-alias-*, 兜底)' —— 真宿主会给出 token,兜底只在 mock/降级时生效。
|
|
32
32
|
* 面板框(rail 8px / desk 12px / 3px 亮缘)照 '.fly-panel' 的结构,不再发明第二套。
|
|
33
33
|
*/
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
--dsw-alias-fg-primary: #e8eef8;
|
|
39
|
-
--dsw-alias-fg-secondary: #9fb0c6;
|
|
40
|
-
--dsw-alias-fg-muted: #6b7d95;
|
|
41
|
-
--dsw-alias-border-l1: rgba(255, 255, 255, 0.10);
|
|
42
|
-
--fly-accent: #f0a55a;
|
|
43
|
-
--fly-accent-dim: rgba(240, 165, 90, 0.16);
|
|
44
|
-
--fly-critical: #ff5f6d;
|
|
45
|
-
--fly-high: #ff9b52;
|
|
46
|
-
--fly-medium: #e8c54a;
|
|
47
|
-
--fly-low: #6fc2ff;
|
|
48
|
-
--fly-info: #8fa3bd;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
* { box-sizing: border-box; }
|
|
52
|
-
body {
|
|
53
|
-
margin: 0;
|
|
54
|
-
padding: var(--fly-desk, 12px);
|
|
55
|
-
background: #05080d;
|
|
56
|
-
font: 13px/1.5 -apple-system, "SF Pro Text", "PingFang SC", system-ui, sans-serif;
|
|
57
|
-
color: var(--dsw-alias-fg-primary);
|
|
58
|
-
}
|
|
59
|
-
|
|
34
|
+
/* 2026-10-19:这三条**只属于独立效果图页**(body padding / 全页重置 / token 兜底),
|
|
35
|
+
不能注入宿主 —— 它们把宿主页面挤了 12px 并刷成不透明底,症状是"侧栏卡片与 App 边缘
|
|
36
|
+
之间多出一条黑边、发光顶不到边"(用户判词)。宿主里每一条声明都自带 var() 兜底,
|
|
37
|
+
不需要这里的 :root。留注释备查,mock 页仍按原样式表渲染。 */
|
|
60
38
|
/* ── 面板框:照 .fly-panel 的结构(rail 8px / 3px 亮缘 / 底部关系型亮缘) ── */
|
|
39
|
+
/* 滚动可达性:宿主页签容器(P3OORG_tabBody)是 flex column + height:100% +
|
|
40
|
+
overflow:hidden,而本面板只声明了宽度与内边距 —— 高度由内容撑开、溢出被父层裁掉,
|
|
41
|
+
整条祖先链上没有滚动容器,所以是"根本不能滚"。
|
|
42
|
+
修法:让面板自己成为滚动容器(可收缩的 flex 子项 + min-height:0;不 grow,短内容不拉伸),
|
|
43
|
+
与 fly-smart 的 .fly-panel 同一处方。max-width 兜住窄抽屉下的横向溢出。 */
|
|
61
44
|
.fly-eg-panel {
|
|
62
45
|
--fly-rail: 8px;
|
|
46
|
+
box-sizing: border-box;
|
|
47
|
+
flex: 0 1 auto;
|
|
48
|
+
min-height: 0;
|
|
63
49
|
width: 760px;
|
|
50
|
+
max-width: 100%;
|
|
51
|
+
overflow-y: auto;
|
|
52
|
+
overscroll-behavior: contain;
|
|
64
53
|
border-radius: 12px;
|
|
65
54
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
66
55
|
background:
|
|
@@ -101,8 +90,12 @@ body {
|
|
|
101
90
|
/* ── 表 ── */
|
|
102
91
|
.fly-eg-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
103
92
|
.fly-eg-table th { text-align: left; font-weight: 500; color: var(--dsw-alias-fg-muted); font-size: 11px; padding: 4px 6px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
104
|
-
|
|
105
|
-
|
|
93
|
+
/* overflow-wrap:anywhere 让单元格的**最小内容宽**变小 —— sha256、长路径这类
|
|
94
|
+
不可断长串不再把表格撑出卡片(word-break 只在排版时折行,不影响最小宽)。 */
|
|
95
|
+
.fly-eg-table td { padding: 5px 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); vertical-align: top; overflow-wrap: anywhere; word-break: break-word; }
|
|
96
|
+
/* id 列整行不折(fact-1 不该断成 fact-
|
|
97
|
+
1),宽让给正文列。 */
|
|
98
|
+
.fly-eg-id { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-fg-muted); white-space: nowrap; }
|
|
106
99
|
.fly-eg-src { font-size: 11px; color: var(--dsw-alias-fg-muted); }
|
|
107
100
|
.fly-eg-src b { color: var(--dsw-alias-fg-secondary); font-weight: 500; }
|
|
108
101
|
.fly-eg-sev { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 999px; display: inline-block; }
|
|
@@ -111,21 +104,54 @@ body {
|
|
|
111
104
|
.fly-eg-sev.medium { color: #1a1506; background: var(--fly-medium); }
|
|
112
105
|
.fly-eg-sev.low { color: #061019; background: var(--fly-low); }
|
|
113
106
|
.fly-eg-sev.info { color: #0b1017; background: var(--fly-info); }
|
|
114
|
-
|
|
115
|
-
.fly-eg-steps
|
|
107
|
+
/* 复现步骤 = 编号清单 + 行尾 ⧉ 单条复制(勾选功能已撤:li 保持 list-item,编号才有)。 */
|
|
108
|
+
.fly-eg-steps { margin: 4px 0 0 0; padding-left: 20px; color: var(--dsw-alias-fg-secondary); font-size: 11.5px; }
|
|
109
|
+
.fly-eg-step { position: relative; margin: 2px 0; padding-right: 20px; }
|
|
110
|
+
.fly-eg-step-text { font-family: ui-monospace, Menlo, monospace; font-size: 11px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; overflow-wrap: anywhere; }
|
|
111
|
+
.fly-eg-step-copy { position: absolute; right: 0; top: 0; border: none; background: none; padding: 0; cursor: pointer; color: var(--dsw-alias-fg-muted); font-size: 11.5px; line-height: 1.5; opacity: 0.55; }
|
|
112
|
+
.fly-eg-step-copy:hover { opacity: 1; color: var(--fly-accent); }
|
|
113
|
+
/* Report 标题行:标题左、动作右。 */
|
|
114
|
+
.fly-eg-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
115
|
+
.fly-eg-copy { font-size: 11px; padding: 2px 9px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-fg-secondary); cursor: default; }
|
|
116
|
+
.fly-eg-copy:hover { color: var(--dsw-alias-fg-primary); border-color: rgba(240, 165, 90, 0.45); }
|
|
117
|
+
.fly-eg-copy.is-done { color: var(--fly-accent); border-color: rgba(240, 165, 90, 0.55); }
|
|
118
|
+
.fly-eg-steps code, .fly-eg-cmd { font-family: ui-monospace, Menlo, monospace; font-size: 11px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
116
119
|
|
|
117
120
|
/* ── 图区:静态分层 + 边胶囊 + 只读(借 xyflow 的形状,不引入拖拽) ── */
|
|
118
|
-
.fly-eg-
|
|
121
|
+
/* 图框:内层 .fly-eg-canvas 是按布局算出来的固定宽度,放不下时横向滚动(不裁剪)。 */
|
|
122
|
+
.fly-eg-graph { position: relative; height: 300px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: radial-gradient(600px 240px at 50% 0%, rgba(255,255,255,0.035), transparent 70%), var(--dsw-alias-bg-layer-1); overflow-x: auto; overflow-y: hidden; }
|
|
123
|
+
.fly-eg-canvas { position: relative; }
|
|
119
124
|
.fly-eg-graph-hint { position: absolute; left: 8px; bottom: 6px; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
|
|
120
125
|
.fly-eg-graph-badge { position: absolute; right: 8px; top: 8px; font-size: 10.5px; padding: 2px 7px; border-radius: 999px; border: 1px solid rgba(255, 95, 109, 0.45); background: rgba(255, 95, 109, 0.12); color: #ffb8be; }
|
|
121
|
-
|
|
126
|
+
/* 节点宽 = 内容宽(max-content):短文本的框刚好包住文字;超过 240px 才省略号。
|
|
127
|
+
真宽由 Graph 用 useLayoutEffect 量 offsetWidth 回填给布局(列宽、线头都用它)。 */
|
|
128
|
+
.fly-eg-node { position: absolute; transform: translate(-50%, -50%); box-sizing: border-box; width: max-content; max-width: 240px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); padding: 5px 9px; font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
129
|
+
/* 色调规则在 hover 块**之前**:hover 靠同等特异性 + 源顺序压过它们,色调 hover 又以更高特异性收尾。 */
|
|
122
130
|
.fly-eg-node.is-finding { border-color: rgba(255, 155, 82, 0.55); background: rgba(255, 155, 82, 0.14); color: #ffd7b0; }
|
|
123
131
|
.fly-eg-node.is-asset { border-color: rgba(111, 194, 255, 0.45); background: rgba(111, 194, 255, 0.12); color: #cbe8ff; }
|
|
132
|
+
/* hover 展开全文:宽回到内容宽(封顶 420px 仍换行),抬高 z-index 压住邻居;
|
|
133
|
+
被截断的内容从此 hover 就能读全 —— 不依赖原生 title(迟 1 秒、样式也不可控)。
|
|
134
|
+
底色在 hover 时转**不透明**:半透明底一旦压到连线或邻近节点上,下层文字会透上来
|
|
135
|
+
与展开的全文混在一起看不清(用户判词)。三档不透明底与各自的色调边框/字色配套。 */
|
|
136
|
+
.fly-eg-node:hover { width: max-content; max-width: 420px; white-space: normal; overflow-wrap: anywhere; overflow: visible; text-overflow: clip; z-index: 5; background: #20272f; border-color: rgba(240, 165, 90, 0.55); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); cursor: default; }
|
|
137
|
+
.fly-eg-node.is-finding:hover { background: #3a2810; }
|
|
138
|
+
.fly-eg-node.is-asset:hover { background: #11293e; }
|
|
124
139
|
.fly-eg-edge { position: absolute; height: 1.5px; background: rgba(240, 165, 90, 0.55); transform-origin: 0 50%; }
|
|
125
140
|
.fly-eg-edge-cap { position: absolute; font-size: 10px; color: var(--fly-accent); background: rgba(7, 11, 18, 0.85); padding: 0 4px; border-radius: 4px; border: 1px solid rgba(240, 165, 90, 0.3); }
|
|
126
141
|
|
|
127
142
|
/* ── 报告区 ── */
|
|
128
|
-
.fly-eg-report { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); padding: 8px 10px; font-size: 11.5px; color: var(--dsw-alias-fg-secondary); }
|
|
143
|
+
.fly-eg-report { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); padding: 8px 10px; font-size: 11.5px; color: var(--dsw-alias-fg-secondary); overflow-wrap: anywhere; }
|
|
144
|
+
/* 报告 Markdown 的排版(受控子集):三级标题 + 段落 + 列表 + 行内 code/bold。 */
|
|
145
|
+
.fly-eg-md-h1 { margin: 2px 0 6px; font-size: 13.5px; font-weight: 650; color: var(--dsw-alias-fg-primary); }
|
|
146
|
+
.fly-eg-md-h2 { margin: 10px 0 4px; font-size: 12.5px; font-weight: 650; color: var(--dsw-alias-fg-primary); }
|
|
147
|
+
.fly-eg-md-h3 { margin: 8px 0 3px; font-size: 11.5px; font-weight: 650; color: var(--dsw-alias-fg-primary); }
|
|
148
|
+
.fly-eg-md-p { margin: 4px 0; line-height: 1.65; }
|
|
149
|
+
.fly-eg-md-list { margin: 4px 0; padding-left: 18px; line-height: 1.65; }
|
|
150
|
+
/* 报告里 "reproducible steps" 之后的有序列表带单条复制(renderMarkdown 认出该行后打标)。 */
|
|
151
|
+
.fly-eg-md-steps { padding-left: 20px; }
|
|
152
|
+
.fly-eg-md-list li { margin: 1px 0; }
|
|
153
|
+
.fly-eg-md-code { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; background: rgba(255, 255, 255, 0.06); padding: 0 4px; border-radius: 4px; overflow-wrap: anywhere; }
|
|
154
|
+
.fly-eg-report b { color: var(--dsw-alias-fg-primary); font-weight: 650; }
|
|
129
155
|
.fly-eg-report-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
130
156
|
.fly-eg-btn { font-size: 11.5px; padding: 3px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-fg-primary); }
|
|
131
157
|
|
|
@@ -146,6 +172,16 @@ body {
|
|
|
146
172
|
.fly-eg-group > summary .fly-eg-count { margin-left: auto; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
|
|
147
173
|
.fly-eg-group table { margin: 0 0 2px; }
|
|
148
174
|
|
|
175
|
+
/* ── 会话作用域条:本会话 / 工作区 + 未归属认领 ── */
|
|
176
|
+
.fly-eg-scope { display: flex; align-items: center; gap: 8px; margin: 8px 0 2px; font-size: 11.5px; }
|
|
177
|
+
.fly-eg-scope-tab { padding: 2px 9px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-fg-secondary); cursor: default; }
|
|
178
|
+
.fly-eg-scope-tab.is-active { border-color: rgba(240, 165, 90, 0.55); background: rgba(240, 165, 90, 0.14); color: #ffd7b0; }
|
|
179
|
+
.fly-eg-scope-count { color: var(--dsw-alias-fg-muted); font-size: 10.5px; }
|
|
180
|
+
.fly-eg-adopt { margin-left: auto; border-color: rgba(240, 165, 90, 0.45); }
|
|
181
|
+
.fly-eg-scope-badge { font-size: 10.5px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-fg-muted); }
|
|
182
|
+
.fly-eg-hint { display: block; margin-top: 3px; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
|
|
183
|
+
.fly-eg-link { color: var(--fly-accent); cursor: default; }
|
|
184
|
+
|
|
149
185
|
.fly-eg-input { width: 100%; font: inherit; font-size: 11.5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-fg-primary); }
|
|
150
186
|
.fly-eg-error { margin: 8px 0; padding: 6px 9px; border-radius: 8px; border: 1px solid rgba(255,95,109,0.45); background: rgba(255,95,109,0.10); color: #ffb8be; font-size: 11.5px; }
|
|
151
187
|
.fly-eg-empty { margin: 14px 0; color: var(--dsw-alias-fg-muted); font-size: 11.5px; }
|
|
@@ -155,7 +191,145 @@ async function getJson(path) {
|
|
|
155
191
|
if (!response.ok) throw new Error(path + " → HTTP " + response.status);
|
|
156
192
|
return await response.json();
|
|
157
193
|
}
|
|
194
|
+
const REPORT_PREVIEW_CAP = 2e4;
|
|
195
|
+
async function copyText(text) {
|
|
196
|
+
try {
|
|
197
|
+
await navigator.clipboard.writeText(text);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
const area = document.createElement("textarea");
|
|
201
|
+
area.value = text;
|
|
202
|
+
area.style.position = "fixed";
|
|
203
|
+
area.style.opacity = "0";
|
|
204
|
+
document.body.appendChild(area);
|
|
205
|
+
area.select();
|
|
206
|
+
let ok = true;
|
|
207
|
+
try {
|
|
208
|
+
document.execCommand("copy");
|
|
209
|
+
} catch {
|
|
210
|
+
ok = false;
|
|
211
|
+
}
|
|
212
|
+
area.remove();
|
|
213
|
+
return ok;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function StepItem({ step }) {
|
|
217
|
+
const [copied, setCopied] = react.useState(false);
|
|
218
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "fly-eg-step", children: [
|
|
219
|
+
/* @__PURE__ */ jsxRuntime.jsx("code", { className: "fly-eg-step-text", children: step }),
|
|
220
|
+
/* @__PURE__ */ jsxRuntime.jsx("button", { className: "fly-eg-step-copy", title: "复制此步", onClick: () => void (async () => {
|
|
221
|
+
if (await copyText(step)) {
|
|
222
|
+
setCopied(true);
|
|
223
|
+
window.setTimeout(() => setCopied(false), 1200);
|
|
224
|
+
}
|
|
225
|
+
})(), children: copied ? "✓" : "⧉" })
|
|
226
|
+
] });
|
|
227
|
+
}
|
|
228
|
+
function renderInline(text, keyPrefix) {
|
|
229
|
+
const out = [];
|
|
230
|
+
const pattern = /(`[^`]+`)|(\*\*[^*]+\*\*)|(?:(?<=^|[^\w])_([^_]+)_(?![\w]))/g;
|
|
231
|
+
let cursor = 0;
|
|
232
|
+
let index = 0;
|
|
233
|
+
for (const match of text.matchAll(pattern)) {
|
|
234
|
+
if (match.index > cursor) out.push(text.slice(cursor, match.index));
|
|
235
|
+
const token = match[0];
|
|
236
|
+
const key = keyPrefix + "-i" + index++;
|
|
237
|
+
if (token.startsWith("`")) out.push(/* @__PURE__ */ jsxRuntime.jsx("code", { className: "fly-eg-md-code", children: token.slice(1, -1) }, key));
|
|
238
|
+
else if (token.startsWith("**")) out.push(/* @__PURE__ */ jsxRuntime.jsx("b", { children: token.slice(2, -2) }, key));
|
|
239
|
+
else out.push(/* @__PURE__ */ jsxRuntime.jsx("i", { children: token.slice(1, -1) }, key));
|
|
240
|
+
cursor = match.index + token.length;
|
|
241
|
+
}
|
|
242
|
+
if (cursor < text.length) out.push(text.slice(cursor));
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
function renderMarkdown(text) {
|
|
246
|
+
const blocks = [];
|
|
247
|
+
const paragraph = [];
|
|
248
|
+
let list = null;
|
|
249
|
+
let stepsNext = false;
|
|
250
|
+
const flushParagraph = () => {
|
|
251
|
+
if (paragraph.length === 0) return;
|
|
252
|
+
const key = "p" + blocks.length;
|
|
253
|
+
const text2 = paragraph.join(" ");
|
|
254
|
+
if (/^reproducible steps/i.test(text2)) stepsNext = true;
|
|
255
|
+
blocks.push(/* @__PURE__ */ jsxRuntime.jsx("p", { className: "fly-eg-md-p", children: renderInline(text2, key) }, key));
|
|
256
|
+
paragraph.length = 0;
|
|
257
|
+
};
|
|
258
|
+
const flushList = () => {
|
|
259
|
+
if (list === null) return;
|
|
260
|
+
const key = "l" + blocks.length;
|
|
261
|
+
const asSteps = stepsNext && list.ordered;
|
|
262
|
+
const items = list.items.map((item, i) => asSteps ? /* @__PURE__ */ jsxRuntime.jsx(StepItem, { step: item.raw }, key + "-" + i) : /* @__PURE__ */ jsxRuntime.jsx("li", { children: item.content }, key + "-" + i));
|
|
263
|
+
const className = asSteps ? "fly-eg-md-list fly-eg-md-steps" : "fly-eg-md-list";
|
|
264
|
+
stepsNext = false;
|
|
265
|
+
blocks.push(list.ordered ? /* @__PURE__ */ jsxRuntime.jsx("ol", { className, children: items }, key) : /* @__PURE__ */ jsxRuntime.jsx("ul", { className, children: items }, key));
|
|
266
|
+
list = null;
|
|
267
|
+
};
|
|
268
|
+
for (const line of text.split("\n")) {
|
|
269
|
+
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
270
|
+
const bullet = /^[-*]\s+(.*)$/.exec(line);
|
|
271
|
+
const ordered = /^\d+\.\s+(.*)$/.exec(line);
|
|
272
|
+
if (heading !== null) {
|
|
273
|
+
flushParagraph();
|
|
274
|
+
flushList();
|
|
275
|
+
stepsNext = false;
|
|
276
|
+
const key = "h" + blocks.length;
|
|
277
|
+
const content = renderInline(heading[2], key);
|
|
278
|
+
const level = heading[1].length;
|
|
279
|
+
blocks.push(level === 1 ? /* @__PURE__ */ jsxRuntime.jsx("h1", { className: "fly-eg-md-h1", children: content }, key) : level === 2 ? /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "fly-eg-md-h2", children: content }, key) : /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "fly-eg-md-h3", children: content }, key));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (bullet !== null || ordered !== null) {
|
|
283
|
+
flushParagraph();
|
|
284
|
+
const isOrdered = ordered !== null;
|
|
285
|
+
const raw = (bullet ?? ordered)[1];
|
|
286
|
+
const content = renderInline(raw, "i" + blocks.length + "-" + (list?.items.length ?? 0));
|
|
287
|
+
if (list === null || list.ordered !== isOrdered) {
|
|
288
|
+
flushList();
|
|
289
|
+
list = { ordered: isOrdered, items: [] };
|
|
290
|
+
}
|
|
291
|
+
list.items.push({ raw, content });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (line.trim() === "") {
|
|
295
|
+
flushParagraph();
|
|
296
|
+
flushList();
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
paragraph.push(line.trim());
|
|
300
|
+
}
|
|
301
|
+
flushParagraph();
|
|
302
|
+
flushList();
|
|
303
|
+
return blocks;
|
|
304
|
+
}
|
|
305
|
+
function assetDepth(assets, parentEdges, id) {
|
|
306
|
+
const parentOf = /* @__PURE__ */ new Map();
|
|
307
|
+
for (const edge of parentEdges) parentOf.set(edge.to, edge.from);
|
|
308
|
+
for (const asset of assets) if (asset.parentId !== void 0 && asset.parentId !== "") parentOf.set(asset.id, asset.parentId);
|
|
309
|
+
const seen = /* @__PURE__ */ new Set([id]);
|
|
310
|
+
let depth = 0;
|
|
311
|
+
let cursor = parentOf.get(id);
|
|
312
|
+
while (cursor !== void 0 && !seen.has(cursor)) {
|
|
313
|
+
seen.add(cursor);
|
|
314
|
+
depth += 1;
|
|
315
|
+
cursor = parentOf.get(cursor);
|
|
316
|
+
}
|
|
317
|
+
return depth;
|
|
318
|
+
}
|
|
158
319
|
function Graph({ nodes, edges, kind }) {
|
|
320
|
+
const nodeRefs = react.useRef(/* @__PURE__ */ new Map());
|
|
321
|
+
const [widths, setWidths] = react.useState(/* @__PURE__ */ new Map());
|
|
322
|
+
react.useLayoutEffect(() => {
|
|
323
|
+
const next = /* @__PURE__ */ new Map();
|
|
324
|
+
let changed = widths.size !== nodes.length;
|
|
325
|
+
for (const node of nodes) {
|
|
326
|
+
const width = nodeRefs.current.get(node.id)?.offsetWidth ?? 0;
|
|
327
|
+
if (width <= 0) continue;
|
|
328
|
+
next.set(node.id, width);
|
|
329
|
+
if (widths.get(node.id) !== width) changed = true;
|
|
330
|
+
}
|
|
331
|
+
if (changed) setWidths(next);
|
|
332
|
+
});
|
|
159
333
|
const byLayer = /* @__PURE__ */ new Map();
|
|
160
334
|
for (const node of nodes) {
|
|
161
335
|
const list = byLayer.get(node.layer) ?? [];
|
|
@@ -163,65 +337,152 @@ function Graph({ nodes, edges, kind }) {
|
|
|
163
337
|
byLayer.set(node.layer, list);
|
|
164
338
|
}
|
|
165
339
|
const layers = [...byLayer.keys()].sort((a, b) => a - b);
|
|
166
|
-
const
|
|
167
|
-
const
|
|
340
|
+
const NODE_MAX = 240;
|
|
341
|
+
const NODE_MIN = 88;
|
|
342
|
+
const textW = (label) => {
|
|
343
|
+
let width = 22;
|
|
344
|
+
for (const ch of label) width += /[\u3000-\u9fff\uff01-\uff60]/.test(ch) ? 11.5 : 6.4;
|
|
345
|
+
return Math.min(NODE_MAX, Math.max(NODE_MIN, Math.round(width)));
|
|
346
|
+
};
|
|
347
|
+
const nodeW = /* @__PURE__ */ new Map();
|
|
348
|
+
for (const node of nodes) nodeW.set(node.id, widths.get(node.id) ?? textW(node.label));
|
|
349
|
+
const colW = /* @__PURE__ */ new Map();
|
|
350
|
+
for (const node of nodes) colW.set(node.layer, Math.max(colW.get(node.layer) ?? 0, nodeW.get(node.id) ?? 0));
|
|
351
|
+
const colGap = 56;
|
|
168
352
|
const xOf = /* @__PURE__ */ new Map();
|
|
169
|
-
let cursor =
|
|
353
|
+
let cursor = 16;
|
|
170
354
|
for (const layer of layers) {
|
|
171
|
-
|
|
172
|
-
|
|
355
|
+
const w = colW.get(layer) ?? NODE_MIN;
|
|
356
|
+
for (const node of byLayer.get(layer) ?? []) xOf.set(node.id, cursor + w / 2);
|
|
357
|
+
cursor += w + colGap;
|
|
173
358
|
}
|
|
174
|
-
const
|
|
359
|
+
const totalWidth = Math.round(cursor - colGap + 16);
|
|
360
|
+
const rowPitch = 40;
|
|
361
|
+
const height = Math.max(96, ...layers.map((layer) => (byLayer.get(layer) ?? []).length * rowPitch));
|
|
175
362
|
const yOf = /* @__PURE__ */ new Map();
|
|
176
363
|
for (const layer of layers) {
|
|
177
364
|
const list = byLayer.get(layer) ?? [];
|
|
178
365
|
list.forEach((node, index) => yOf.set(node.id, height / (list.length + 1) * (index + 1)));
|
|
179
366
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
367
|
+
const markerId = "fly-eg-arrow-" + kind;
|
|
368
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-graph", style: { height: height + 34 }, children: [
|
|
369
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-canvas", style: { width: totalWidth, height: height + 34 }, children: [
|
|
370
|
+
/* @__PURE__ */ jsxRuntime.jsxs("svg", { className: "fly-eg-wires", width: totalWidth, height: height + 34, viewBox: `0 0 ${totalWidth} ${height + 34}`, children: [
|
|
371
|
+
/* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsx("marker", { id: markerId, markerWidth: "7", markerHeight: "7", refX: "6", refY: "3.5", orient: "auto", markerUnits: "userSpaceOnUse", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M0,0.6 L6,3.5 L0,6.4 z", fill: "rgba(240, 165, 90, 0.85)" }) }) }),
|
|
372
|
+
edges.filter((edge) => xOf.has(edge.from) && xOf.has(edge.to)).map((edge) => {
|
|
373
|
+
const w1 = nodeW.get(edge.from) ?? NODE_MIN;
|
|
374
|
+
const w2 = nodeW.get(edge.to) ?? NODE_MIN;
|
|
375
|
+
const cx1 = xOf.get(edge.from) ?? 0;
|
|
376
|
+
const cx2 = xOf.get(edge.to) ?? 0;
|
|
377
|
+
const y1 = yOf.get(edge.from) ?? 0;
|
|
378
|
+
const y2 = yOf.get(edge.to) ?? 0;
|
|
379
|
+
let d;
|
|
380
|
+
if (cx2 > cx1) {
|
|
381
|
+
const x1 = cx1 + w1 / 2 + 3;
|
|
382
|
+
const x2 = cx2 - w2 / 2 - 3;
|
|
383
|
+
const midX = (x1 + x2) / 2;
|
|
384
|
+
d = "M " + x1 + " " + y1 + " H " + midX + " V " + y2 + " H " + x2;
|
|
385
|
+
} else {
|
|
386
|
+
const x1 = cx1 + w1 / 2 + 3;
|
|
387
|
+
const x2 = cx2 - w2 / 2 - 3;
|
|
388
|
+
const floor = height + 16;
|
|
389
|
+
d = "M " + x1 + " " + y1 + " H " + (x1 + 14) + " V " + floor + " H " + (x2 - 14) + " V " + y2 + " H " + x2;
|
|
390
|
+
}
|
|
391
|
+
return /* @__PURE__ */ jsxRuntime.jsx("path", { className: "fly-eg-wire", markerEnd: `url(#${markerId})`, d }, kind + edge.from + edge.to);
|
|
392
|
+
})
|
|
393
|
+
] }),
|
|
394
|
+
nodes.map((node) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
395
|
+
"div",
|
|
396
|
+
{
|
|
397
|
+
ref: (element) => {
|
|
398
|
+
if (element !== null) nodeRefs.current.set(node.id, element);
|
|
399
|
+
else nodeRefs.current.delete(node.id);
|
|
400
|
+
},
|
|
401
|
+
className: "fly-eg-node" + (node.tone === "finding" ? " is-finding" : node.tone === "asset" ? " is-asset" : ""),
|
|
402
|
+
style: { left: xOf.get(node.id), top: yOf.get(node.id) },
|
|
403
|
+
children: node.label
|
|
404
|
+
},
|
|
405
|
+
node.id
|
|
406
|
+
))
|
|
407
|
+
] }),
|
|
196
408
|
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-graph-hint", children: "折线走层间间隙 · 静态分层 · 只读" })
|
|
197
409
|
] });
|
|
198
410
|
}
|
|
199
|
-
function Panel() {
|
|
200
|
-
const [
|
|
411
|
+
function Panel({ sessionId, useSessions }) {
|
|
412
|
+
const [override, setOverride] = react.useState(() => localStorage.getItem(STORE_KEY) ?? "");
|
|
413
|
+
const liveCwd = useSessions === void 0 || sessionId === void 0 ? void 0 : useSessions((state2) => state2.byId[sessionId]?.cwd);
|
|
414
|
+
const workspace = override.trim() !== "" ? override : liveCwd ?? "";
|
|
415
|
+
const session = sessionId ?? "";
|
|
416
|
+
const [scope, setScope] = react.useState("session");
|
|
201
417
|
const [state, setState] = react.useState(null);
|
|
202
418
|
const [error, setError] = react.useState("");
|
|
203
419
|
const [view, setView] = react.useState("table");
|
|
204
420
|
const [report, setReport] = react.useState("");
|
|
205
|
-
const
|
|
421
|
+
const [busy, setBusy] = react.useState(false);
|
|
422
|
+
const [copied, setCopied] = react.useState(false);
|
|
423
|
+
const copyReport = async () => {
|
|
424
|
+
if (report === "") return;
|
|
425
|
+
if (await copyText(report)) {
|
|
426
|
+
setCopied(true);
|
|
427
|
+
window.setTimeout(() => setCopied(false), 1600);
|
|
428
|
+
} else {
|
|
429
|
+
setError("复制失败:浏览器拒绝了剪贴板写入");
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const load = react.useCallback(async (target, mode) => {
|
|
206
433
|
if (target.trim() === "") return;
|
|
207
434
|
setError("");
|
|
435
|
+
const query = "workspace=" + encodeURIComponent(target) + "&session=" + encodeURIComponent(session) + "&scope=" + mode;
|
|
208
436
|
try {
|
|
209
437
|
const [next, rendered] = await Promise.all([
|
|
210
|
-
getJson(BASE + "/state?
|
|
211
|
-
getJson(BASE + "/report?
|
|
438
|
+
getJson(BASE + "/state?" + query),
|
|
439
|
+
getJson(BASE + "/report?" + query).catch(() => ({ markdown: "" }))
|
|
212
440
|
]);
|
|
213
441
|
setState(next);
|
|
214
442
|
setReport(rendered.markdown);
|
|
215
|
-
localStorage.setItem(STORE_KEY, target);
|
|
216
443
|
} catch (failure) {
|
|
217
|
-
|
|
444
|
+
const message = String(failure.message ?? failure);
|
|
445
|
+
setError(message.includes("HTTP 404") ? "宿主没有注册 " + BASE + " 路由(面板行未激活)· 重启 DSH 或重载 profile 后再试 · " + message : message);
|
|
218
446
|
setState(null);
|
|
219
447
|
}
|
|
220
|
-
}, []);
|
|
448
|
+
}, [session]);
|
|
449
|
+
react.useEffect(() => {
|
|
450
|
+
void load(workspace, scope);
|
|
451
|
+
}, [load, workspace, scope]);
|
|
221
452
|
react.useEffect(() => {
|
|
222
|
-
|
|
223
|
-
|
|
453
|
+
const refresh = () => {
|
|
454
|
+
if (document.visibilityState === "visible") void load(workspace, scope);
|
|
455
|
+
};
|
|
456
|
+
document.addEventListener("visibilitychange", refresh);
|
|
457
|
+
window.addEventListener("focus", refresh);
|
|
458
|
+
return () => {
|
|
459
|
+
document.removeEventListener("visibilitychange", refresh);
|
|
460
|
+
window.removeEventListener("focus", refresh);
|
|
461
|
+
};
|
|
462
|
+
}, [load, workspace, scope]);
|
|
463
|
+
const adopt = async () => {
|
|
464
|
+
if (busy || workspace.trim() === "" || session === "") return;
|
|
465
|
+
setBusy(true);
|
|
466
|
+
try {
|
|
467
|
+
const response = await fetch(BASE + "/adopt", {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { "content-type": "application/json" },
|
|
470
|
+
body: JSON.stringify({ workspace, session, all: true })
|
|
471
|
+
});
|
|
472
|
+
if (!response.ok) throw new Error("adopt → HTTP " + response.status);
|
|
473
|
+
await load(workspace, scope);
|
|
474
|
+
} catch (failure) {
|
|
475
|
+
setError(String(failure.message ?? failure));
|
|
476
|
+
} finally {
|
|
477
|
+
setBusy(false);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
224
480
|
const projection = state?.projection;
|
|
481
|
+
const legacy = projection?.legacy ?? { facts: 0, findings: 0, assets: 0, goal: 0 };
|
|
482
|
+
const totals = projection?.workspaceCounts ?? projection?.counts;
|
|
483
|
+
const legacyTotal = legacy.facts + legacy.findings + legacy.assets + legacy.goal;
|
|
484
|
+
const scopedTotal = projection === void 0 ? 0 : projection.counts.facts + projection.counts.findings + projection.counts.assets;
|
|
485
|
+
const workspaceTotal = totals === void 0 ? 0 : totals.facts + totals.findings + totals.assets;
|
|
225
486
|
const groups = react.useMemo(() => {
|
|
226
487
|
const map = /* @__PURE__ */ new Map();
|
|
227
488
|
for (const fact of projection?.facts ?? []) {
|
|
@@ -234,20 +495,26 @@ function Panel() {
|
|
|
234
495
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-head", children: [
|
|
235
496
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-title", children: [
|
|
236
497
|
/* @__PURE__ */ jsxRuntime.jsx("h1", { children: "Engagement" }),
|
|
498
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-scope-badge", children: session === "" ? "未绑定会话" : "会话 " + session.slice(0, 8) }),
|
|
237
499
|
projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-badge", children: projection.goal.target })
|
|
238
500
|
] }),
|
|
239
|
-
/* @__PURE__ */ jsxRuntime.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
501
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-meta", children: [
|
|
502
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
503
|
+
"input",
|
|
504
|
+
{
|
|
505
|
+
className: "fly-eg-input",
|
|
506
|
+
value: workspace,
|
|
507
|
+
placeholder: "工作区绝对路径(默认跟随当前会话 cwd)",
|
|
508
|
+
onChange: (event) => setOverride(event.target.value),
|
|
509
|
+
onKeyDown: (event) => {
|
|
510
|
+
if (event.key !== "Enter") return;
|
|
511
|
+
localStorage.setItem(STORE_KEY, override);
|
|
512
|
+
void load(workspace, scope);
|
|
513
|
+
}
|
|
248
514
|
}
|
|
249
|
-
|
|
250
|
-
|
|
515
|
+
),
|
|
516
|
+
override.trim() === "" && liveCwd !== void 0 && liveCwd !== "" ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-hint", children: "跟随会话 cwd · 输入可覆盖" }) : null
|
|
517
|
+
] }),
|
|
251
518
|
projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
252
519
|
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-meta", children: /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
|
|
253
520
|
"objective: ",
|
|
@@ -262,6 +529,20 @@ function Panel() {
|
|
|
262
529
|
] }),
|
|
263
530
|
error !== "" ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-error", children: error }) : null,
|
|
264
531
|
projection === void 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-empty", children: "还没读取记录(填工作区后回车)" }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
532
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-scope", children: [
|
|
533
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-scope-tab" + (scope === "session" ? " is-active" : ""), onClick: () => setScope("session"), children: "本会话" }),
|
|
534
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-scope-tab" + (scope === "workspace" ? " is-active" : ""), onClick: () => setScope("workspace"), children: "工作区" }),
|
|
535
|
+
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-scope-count", children: [
|
|
536
|
+
"工作区合计 ",
|
|
537
|
+
workspaceTotal,
|
|
538
|
+
" 条"
|
|
539
|
+
] }),
|
|
540
|
+
legacyTotal > 0 && session !== "" ? /* @__PURE__ */ jsxRuntime.jsx("button", { className: "fly-eg-btn fly-eg-adopt", disabled: busy, onClick: () => void adopt(), children: busy ? "认领中…" : "认领 " + legacyTotal + " 条未归属记录" }) : null
|
|
541
|
+
] }),
|
|
542
|
+
scopedTotal === 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-empty", children: [
|
|
543
|
+
scope === "session" ? workspaceTotal > 0 ? "本会话还没有记录;工作区共 " + workspaceTotal + " 条" : "本会话还没有记录" : "这个工作区还没有记录",
|
|
544
|
+
scope === "session" && workspaceTotal > 0 ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-link", onClick: () => setScope("workspace"), children: " 看工作区 →" }) : null
|
|
545
|
+
] }) : null,
|
|
265
546
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-counters", children: [
|
|
266
547
|
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter", children: [
|
|
267
548
|
"facts",
|
|
@@ -308,7 +589,7 @@ function Panel() {
|
|
|
308
589
|
/* @__PURE__ */ jsxRuntime.jsx("td", { className: "fly-eg-id", children: finding.id }),
|
|
309
590
|
/* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-sev " + (finding.severity ?? "info"), children: finding.severity ?? "info" }) }),
|
|
310
591
|
/* @__PURE__ */ jsxRuntime.jsx("td", { children: finding.title }),
|
|
311
|
-
/* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("ol", { className: "fly-eg-steps", children: finding.reproducibleSteps.map((step, index) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
592
|
+
/* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("ol", { className: "fly-eg-steps", children: finding.reproducibleSteps.map((step, index) => /* @__PURE__ */ jsxRuntime.jsx(StepItem, { step }, index)) }) })
|
|
312
593
|
] }, finding.id)) }) })
|
|
313
594
|
] }),
|
|
314
595
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
|
|
@@ -328,8 +609,11 @@ function Panel() {
|
|
|
328
609
|
] }, tool))
|
|
329
610
|
] }),
|
|
330
611
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
|
|
331
|
-
/* @__PURE__ */ jsxRuntime.
|
|
332
|
-
|
|
612
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section-head", children: [
|
|
613
|
+
/* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Report" }),
|
|
614
|
+
/* @__PURE__ */ jsxRuntime.jsx("button", { className: "fly-eg-copy" + (copied ? " is-done" : ""), disabled: report === "", onClick: () => void copyReport(), children: copied ? "已复制 ✓" : "复制 Markdown" })
|
|
615
|
+
] }),
|
|
616
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-report", children: report === "" ? "(还没有可渲染的报告)" : renderMarkdown(report.length > REPORT_PREVIEW_CAP ? report.slice(0, REPORT_PREVIEW_CAP) + "\n\n**…(报告超过 " + REPORT_PREVIEW_CAP + " 字符,已截断预览)**" : report) })
|
|
333
617
|
] })
|
|
334
618
|
] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
335
619
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
|
|
@@ -352,7 +636,7 @@ function Panel() {
|
|
|
352
636
|
Graph,
|
|
353
637
|
{
|
|
354
638
|
kind: "parent",
|
|
355
|
-
nodes: projection.assets.map((asset) => ({ id: asset.id, label: asset.value + " (" + asset.type + ")", layer:
|
|
639
|
+
nodes: projection.assets.map((asset) => ({ id: asset.id, label: asset.value + " (" + asset.type + ")", layer: assetDepth(projection.assets, projection.edges.filter((edge) => edge.kind === "parent"), asset.id), tone: "asset" })),
|
|
356
640
|
edges: projection.edges.filter((edge) => edge.kind === "parent")
|
|
357
641
|
}
|
|
358
642
|
)
|
package/lib/fold.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* 为什么单独成文件:这两半是两个挂载点(`tools/index.js` 由 preset 按会话挂;根 `index.js`
|
|
5
5
|
* 是宿主平面的面板行),**投影口径必须逐字一致**,否则面板上的计数和 `eng_state` 会讲两个故事。
|
|
6
6
|
* 同包内 import ⇒ 不需要"两份逐字节相同"的钉子(那是跨包才付的代价)。
|
|
7
|
+
*
|
|
8
|
+
* v2 起事件带 `session`:一个工作区一条账本,但**每个会话各自一条证据链**。
|
|
9
|
+
* 没有 session 的旧事件归入「未归属」(v1 遗留),可以经 `eng_adopt` 认领。
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
export const FACT_KINDS = ['net', 'service', 'http', 'binary', 'code', 'vuln', 'info']
|
|
@@ -19,13 +22,18 @@ export function ledgerPath(workspace) {
|
|
|
19
22
|
return String(workspace).replace(/\/+$/u, '') + '/' + LEDGER_DIR + '/' + LEDGER_FILE
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/** 事件信封上的会话身份:缺省 / 空串都折成 null(= v1 遗留的未归属)。 */
|
|
26
|
+
export function sessionOf(event) {
|
|
27
|
+
return typeof event?.session === 'string' && event.session !== '' ? event.session : null
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* 折叠事件流 ⇒ 完整状态(**确定性**:同一个文件永远折出同一张图)。
|
|
24
|
-
* 坏引用计入 `refused
|
|
32
|
+
* 坏引用计入 `refused`(不是静默丢弃);认领别人的记录计入 `skipped`。
|
|
25
33
|
*/
|
|
26
34
|
export function foldEvents(lines) {
|
|
27
|
-
const state = {
|
|
28
|
-
const
|
|
35
|
+
const state = { goals: [], facts: [], findings: [], assets: [], edges: [], refused: 0, skipped: 0 }
|
|
36
|
+
const allRows = () => new Map([...state.facts, ...state.findings, ...state.assets].map(row => [row.id, row]))
|
|
29
37
|
for (const line of lines) {
|
|
30
38
|
if (line.trim() === '') continue
|
|
31
39
|
let event
|
|
@@ -36,60 +44,131 @@ export function foldEvents(lines) {
|
|
|
36
44
|
continue
|
|
37
45
|
}
|
|
38
46
|
if (event.op === 'goal') {
|
|
39
|
-
|
|
40
|
-
state.
|
|
47
|
+
const session = sessionOf(event)
|
|
48
|
+
if (state.goals.some(row => row.session === session)) { state.refused += 1; continue }
|
|
49
|
+
state.goals.push({ session, goal: event.goal, at: event.at ?? null })
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (event.op === 'adopt') {
|
|
53
|
+
const session = sessionOf(event)
|
|
54
|
+
if (session === null) { state.refused += 1; continue }
|
|
55
|
+
const rows = allRows()
|
|
56
|
+
for (const id of event.ids ?? []) {
|
|
57
|
+
const row = rows.get(id)
|
|
58
|
+
if (row === undefined) { state.refused += 1; continue }
|
|
59
|
+
if ((row.session ?? null) !== null) { state.skipped += 1; continue }
|
|
60
|
+
row.session = session
|
|
61
|
+
}
|
|
62
|
+
if (event.goal === true) {
|
|
63
|
+
const legacy = state.goals.find(row => row.session === null)
|
|
64
|
+
if (legacy === undefined || state.goals.some(row => row.session === session)) state.skipped += 1
|
|
65
|
+
else legacy.session = session
|
|
66
|
+
}
|
|
67
|
+
// 边跟着端点走:两端都归本会话的未归属边一起认领(保持账本内部一致;渲染不依赖这一步)。
|
|
68
|
+
for (const edge of state.edges) {
|
|
69
|
+
if ((edge.session ?? null) !== null) continue
|
|
70
|
+
const from = rows.get(edge.from)
|
|
71
|
+
const to = rows.get(edge.to)
|
|
72
|
+
if (from !== undefined && to !== undefined && from.session === session && to.session === session) edge.session = session
|
|
73
|
+
}
|
|
41
74
|
continue
|
|
42
75
|
}
|
|
43
76
|
if (event.op !== 'record') continue
|
|
44
|
-
const
|
|
77
|
+
const session = sessionOf(event)
|
|
78
|
+
const ids = new Set(allRows().keys())
|
|
45
79
|
for (const asset of event.assets ?? []) {
|
|
46
80
|
if (asset.parentId !== undefined && asset.parentId !== '' && !ids.has(asset.parentId)) { state.refused += 1; continue }
|
|
47
|
-
state.assets.push(asset)
|
|
81
|
+
state.assets.push({ ...asset, session })
|
|
48
82
|
ids.add(asset.id)
|
|
49
83
|
}
|
|
50
84
|
for (const fact of event.facts ?? []) {
|
|
51
|
-
state.facts.push(fact)
|
|
85
|
+
state.facts.push({ ...fact, session })
|
|
52
86
|
ids.add(fact.id)
|
|
53
87
|
}
|
|
54
88
|
for (const finding of event.findings ?? []) {
|
|
55
89
|
if (finding.affectedAssetId !== undefined && finding.affectedAssetId !== '' && !ids.has(finding.affectedAssetId)) { state.refused += 1; continue }
|
|
56
|
-
state.findings.push(finding)
|
|
90
|
+
state.findings.push({ ...finding, session })
|
|
57
91
|
ids.add(finding.id)
|
|
58
92
|
}
|
|
59
93
|
for (const edge of event.edges ?? []) {
|
|
60
94
|
if (!ids.has(edge.from) || !ids.has(edge.to)) { state.refused += 1; continue }
|
|
61
|
-
state.edges.push(edge)
|
|
95
|
+
state.edges.push({ ...edge, session })
|
|
62
96
|
}
|
|
63
97
|
}
|
|
64
98
|
return state
|
|
65
99
|
}
|
|
66
100
|
|
|
101
|
+
/** 某个会话的 goal(`session` 为 null 即 v1 遗留)。 */
|
|
102
|
+
export function goalFor(state, session) {
|
|
103
|
+
return state.goals.find(row => row.session === (session ?? null))?.goal ?? null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const countsOf = rows => ({
|
|
107
|
+
facts: rows.facts.length,
|
|
108
|
+
findings: rows.findings.length,
|
|
109
|
+
assets: rows.assets.length,
|
|
110
|
+
edges: rows.edges.length,
|
|
111
|
+
})
|
|
112
|
+
|
|
67
113
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
114
|
+
* 作用域视图:`scope='session'` 只留该会话的行,`scope='workspace'` 合并全部。
|
|
115
|
+
*
|
|
116
|
+
* 两个读数永远都给:`counts` 是**当前作用域**的计数,`workspaceCounts` 是全工作区的。
|
|
117
|
+
* `legacy` 单独报未归属条数(v1 遗留),面板据此决定要不要给「认领」按钮。
|
|
70
118
|
*/
|
|
71
|
-
export function
|
|
119
|
+
export function scopeView(state, options = {}, windowSize = 200) {
|
|
120
|
+
const scope = options.scope === 'workspace' ? 'workspace' : 'session'
|
|
121
|
+
const session = scope === 'session' ? (options.session ?? null) : null
|
|
122
|
+
const inScope = row => scope === 'workspace' || (row.session ?? null) === session
|
|
72
123
|
const cut = rows => rows.length <= windowSize
|
|
73
124
|
? { rows, dropped: 0 }
|
|
74
125
|
: { rows: rows.slice(rows.length - windowSize), dropped: rows.length - windowSize }
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
126
|
+
|
|
127
|
+
const factsAll = state.facts.filter(inScope)
|
|
128
|
+
const findingsAll = state.findings.filter(inScope)
|
|
129
|
+
const assetsAll = state.assets.filter(inScope)
|
|
130
|
+
// 边**跟随端点**:两端都在作用域内才算这条作用域的边(边自己的 session 只是出处,
|
|
131
|
+
// 不参与过滤 —— 否则 adopt 只认领节点后,边会以未归属身份残留、图上看不到连线)。
|
|
132
|
+
const scopedIds = new Set([...factsAll, ...findingsAll, ...assetsAll].map(row => row.id))
|
|
133
|
+
const edgesAll = state.edges.filter(edge => scopedIds.has(edge.from) && scopedIds.has(edge.to))
|
|
134
|
+
const facts = cut(factsAll)
|
|
135
|
+
const findings = cut(findingsAll)
|
|
136
|
+
const assets = cut(assetsAll)
|
|
78
137
|
const kept = new Set([...facts.rows, ...findings.rows, ...assets.rows].map(row => row.id))
|
|
79
138
|
const dropped = facts.dropped + findings.dropped + assets.dropped
|
|
80
|
-
|
|
81
|
-
goal: state.goal,
|
|
139
|
+
const scoped = {
|
|
82
140
|
facts: facts.rows,
|
|
83
141
|
findings: findings.rows,
|
|
84
142
|
assets: assets.rows,
|
|
85
|
-
edges:
|
|
86
|
-
|
|
143
|
+
edges: edgesAll.filter(edge => kept.has(edge.from) && kept.has(edge.to)),
|
|
144
|
+
}
|
|
145
|
+
const goals = scope === 'workspace' ? state.goals : state.goals.filter(row => row.session === session)
|
|
146
|
+
return {
|
|
147
|
+
...scoped,
|
|
148
|
+
scope,
|
|
149
|
+
session,
|
|
150
|
+
goal: goals.length === 1 ? goals[0].goal : null,
|
|
151
|
+
goals: goals.map(row => ({ session: row.session, goal: row.goal })),
|
|
152
|
+
counts: countsOf({ facts: factsAll, findings: findingsAll, assets: assetsAll, edges: edgesAll }),
|
|
153
|
+
workspaceCounts: countsOf(state),
|
|
154
|
+
legacy: {
|
|
155
|
+
facts: state.facts.filter(row => (row.session ?? null) === null).length,
|
|
156
|
+
findings: state.findings.filter(row => (row.session ?? null) === null).length,
|
|
157
|
+
assets: state.assets.filter(row => (row.session ?? null) === null).length,
|
|
158
|
+
goal: state.goals.filter(row => row.session === null).length,
|
|
159
|
+
},
|
|
87
160
|
dropped,
|
|
88
161
|
refused: state.refused,
|
|
162
|
+
skipped: state.skipped,
|
|
89
163
|
truncated: dropped > 0,
|
|
90
164
|
}
|
|
91
165
|
}
|
|
92
166
|
|
|
167
|
+
/** 兼容旧调用:整份账本的窗口视图。 */
|
|
168
|
+
export function windowView(state, windowSize) {
|
|
169
|
+
return scopeView(state, { scope: 'workspace' }, windowSize)
|
|
170
|
+
}
|
|
171
|
+
|
|
93
172
|
/** 下一个确定性 id `<kind>-<n>`。 */
|
|
94
173
|
export function nextId(prefix, rows) {
|
|
95
174
|
let max = 0
|
|
@@ -100,32 +179,35 @@ export function nextId(prefix, rows) {
|
|
|
100
179
|
return prefix + '-' + (max + 1)
|
|
101
180
|
}
|
|
102
181
|
|
|
103
|
-
/**
|
|
104
|
-
export function renderReport(workspace,
|
|
182
|
+
/** 从视图渲染 Markdown 报告(`eng_report` 与 `/fly/engagement/report` 同一份)。 */
|
|
183
|
+
export function renderReport(workspace, view) {
|
|
105
184
|
const lines = []
|
|
185
|
+
const scopeLabel = view.scope === 'workspace' ? '整个工作区' : '会话 ' + (view.session ?? '(未归属)')
|
|
106
186
|
lines.push('# Engagement report')
|
|
107
187
|
lines.push('')
|
|
108
188
|
lines.push('- workspace: `' + workspace + '`')
|
|
109
189
|
lines.push('- ledger: `' + ledgerPath(workspace) + '`')
|
|
110
|
-
lines.push('-
|
|
111
|
-
|
|
190
|
+
lines.push('- scope: ' + scopeLabel)
|
|
191
|
+
lines.push('- counts: facts=' + view.counts.facts + ' findings=' + view.counts.findings + ' assets=' + view.counts.assets + ' edges=' + view.counts.edges)
|
|
192
|
+
if (view.scope === 'session') lines.push('- workspace totals: facts=' + view.workspaceCounts.facts + ' findings=' + view.workspaceCounts.findings + ' assets=' + view.workspaceCounts.assets + ' edges=' + view.workspaceCounts.edges)
|
|
193
|
+
if (view.refused > 0) lines.push('- refused writes: ' + view.refused + '(引用不存在的 id,已拒绝)')
|
|
112
194
|
lines.push('', '## Goal', '')
|
|
113
|
-
if (
|
|
114
|
-
lines.push('
|
|
195
|
+
if (view.goal === null) {
|
|
196
|
+
lines.push('**未设置(还没有 eng_record 带 goal)**')
|
|
115
197
|
} else {
|
|
116
|
-
lines.push('- target: ' +
|
|
117
|
-
lines.push('- objective: ' +
|
|
118
|
-
lines.push('- authorization: ' + (
|
|
198
|
+
lines.push('- target: ' + view.goal.target)
|
|
199
|
+
lines.push('- objective: ' + view.goal.objective)
|
|
200
|
+
lines.push('- authorization: ' + (view.goal.authorization === undefined || view.goal.authorization === '' ? '**未记录**(这是审计事实,不是门禁)' : view.goal.authorization))
|
|
119
201
|
}
|
|
120
202
|
lines.push('', '## Assets')
|
|
121
|
-
if (
|
|
122
|
-
for (const asset of
|
|
203
|
+
if (view.assets.length === 0) lines.push('', 'none')
|
|
204
|
+
for (const asset of view.assets) {
|
|
123
205
|
const parent = asset.parentId === undefined || asset.parentId === '' ? '' : ' ← parent `' + asset.parentId + '`'
|
|
124
206
|
lines.push('- `' + asset.id + '` [' + asset.type + '] ' + asset.value + parent)
|
|
125
207
|
}
|
|
126
208
|
lines.push('', '## Findings')
|
|
127
|
-
if (
|
|
128
|
-
for (const finding of
|
|
209
|
+
if (view.findings.length === 0) lines.push('', 'none')
|
|
210
|
+
for (const finding of view.findings) {
|
|
129
211
|
lines.push('', '### `' + finding.id + '` ' + finding.title + ' — **' + (finding.severity ?? 'info') + '**')
|
|
130
212
|
if (finding.description !== undefined && finding.description !== '') lines.push('', finding.description)
|
|
131
213
|
if (finding.affectedAssetId !== undefined && finding.affectedAssetId !== '') lines.push('', 'affected asset: `' + finding.affectedAssetId + '`')
|
|
@@ -133,14 +215,14 @@ export function renderReport(workspace, state) {
|
|
|
133
215
|
for (const [index, step] of finding.reproducibleSteps.entries()) lines.push((index + 1) + '. ' + step)
|
|
134
216
|
}
|
|
135
217
|
lines.push('', '## Facts')
|
|
136
|
-
if (
|
|
137
|
-
for (const fact of
|
|
218
|
+
if (view.facts.length === 0) lines.push('', 'none')
|
|
219
|
+
for (const fact of view.facts) {
|
|
138
220
|
const source = fact.source === undefined ? '' : ' _(source: ' + [fact.source.tool, fact.source.operation].filter(Boolean).join(' · ') + ')_'
|
|
139
221
|
lines.push('- `' + fact.id + '` [' + (fact.kind ?? 'info') + ']' + (fact.target === undefined ? '' : ' ' + fact.target) + ' — ' + fact.detail + source)
|
|
140
222
|
}
|
|
141
223
|
lines.push('', '## Edges')
|
|
142
|
-
if (
|
|
143
|
-
for (const edge of
|
|
224
|
+
if (view.edges.length === 0) lines.push('', 'none')
|
|
225
|
+
for (const edge of view.edges) lines.push('- `' + edge.from + '` -[' + edge.kind + ']→ `' + edge.to + '`')
|
|
144
226
|
lines.push('')
|
|
145
227
|
return lines.join('\n')
|
|
146
228
|
}
|
package/package.json
CHANGED
package/tools/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import {
|
|
3
3
|
ASSET_TYPES, EDGE_KINDS, FACT_KINDS, SEVERITIES,
|
|
4
|
-
foldEvents, ledgerPath, nextId, renderReport,
|
|
4
|
+
foldEvents, ledgerPath, nextId, renderReport, scopeView, sessionOf,
|
|
5
5
|
} from '../lib/fold.mjs'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -95,6 +95,16 @@ function normalizeRecord(args) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
const workspaceParam = { type: 'string', required: true, description: 'Absolute workspace path; the ledger lives in <workspace>/.fly-engagement/events.jsonl' }
|
|
98
|
+
const sessionParam = { type: 'string', description: 'Session identity; defaults to the calling agent (exec.agent.id). Explicit value wins — used for backfill/migration.' }
|
|
99
|
+
const scopeParam = { type: 'string', enum: ['session', 'workspace'], description: 'session (default): only this session\'s chain; workspace: every session in this workspace.' }
|
|
100
|
+
|
|
101
|
+
/** 记录归属:显式 `session` 优先,否则取调用它的 agent(`ToolExecutionInput.agent.id` 就是会话身份)。 */
|
|
102
|
+
function resolveSession(args, exec) {
|
|
103
|
+
const explicit = typeof args.session === 'string' ? args.session.trim() : ''
|
|
104
|
+
if (explicit !== '') return explicit
|
|
105
|
+
const agentId = exec?.agent?.id
|
|
106
|
+
return typeof agentId === 'string' && agentId !== '' ? agentId : null
|
|
107
|
+
}
|
|
98
108
|
|
|
99
109
|
export function apply(ctx) {
|
|
100
110
|
const shell = ctx.shell
|
|
@@ -104,7 +114,8 @@ export function apply(ctx) {
|
|
|
104
114
|
description: 'Record engagement evidence into the workspace ledger: goal (once), facts, findings (reproducible steps required), assets and edges. Returns deterministic ids for later reference.',
|
|
105
115
|
parameters: {
|
|
106
116
|
workspace: workspaceParam,
|
|
107
|
-
|
|
117
|
+
session: sessionParam,
|
|
118
|
+
goal: { type: 'object', additionalProperties: true, description: 'Once per session: { target, objective, authorization? }' },
|
|
108
119
|
facts: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Facts: { kind?, target?, detail (required), confidence?, source? }' },
|
|
109
120
|
findings: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Findings: { title, severity?, description?, reproducibleSteps (required), affectedAssetId? }' },
|
|
110
121
|
assets: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Assets: { type, value, parentId?, meta? }' },
|
|
@@ -117,12 +128,14 @@ export function apply(ctx) {
|
|
|
117
128
|
if (normalized.goal === null && normalized.facts.length === 0 && normalized.findings.length === 0 && normalized.assets.length === 0 && normalized.edges.length === 0) {
|
|
118
129
|
throw argumentError('eng_record 一次要写至少一样东西(goal / facts / findings / assets / edges 全空)')
|
|
119
130
|
}
|
|
131
|
+
const session = resolveSession(args, exec)
|
|
120
132
|
const file = ledgerPath(args.workspace)
|
|
121
133
|
const dir = file.slice(0, file.lastIndexOf('/'))
|
|
122
134
|
await runCmd(shell, ['sh', '-c', 'mkdir -p ' + shq(dir)], exec)
|
|
123
135
|
const existing = foldEvents((await readText(shell, file, exec)).split('\n'))
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
const existingGoal = existing.goals.find(row => row.session === session)?.goal ?? null
|
|
137
|
+
if (normalized.goal !== null && existingGoal !== null) {
|
|
138
|
+
throw argumentError('这个会话已经有 goal 了(target=' + existingGoal.target + ')—— 一个会话只有一个 goal;要重新开始请换一个会话')
|
|
126
139
|
}
|
|
127
140
|
const ids = new Set([...existing.facts.map(row => row.id), ...existing.findings.map(row => row.id), ...existing.assets.map(row => row.id)])
|
|
128
141
|
const created = { facts: [], findings: [], assets: [], edges: [] }
|
|
@@ -154,8 +167,8 @@ export function apply(ctx) {
|
|
|
154
167
|
created.edges.push({ ...edge })
|
|
155
168
|
}
|
|
156
169
|
const events = []
|
|
157
|
-
if (normalized.goal !== null) events.push({ op: 'goal', at: new Date().toISOString(), goal: normalized.goal })
|
|
158
|
-
events.push({ op: 'record', at: new Date().toISOString(), facts: created.facts, findings: created.findings, assets: created.assets, edges: created.edges })
|
|
170
|
+
if (normalized.goal !== null) events.push({ op: 'goal', at: new Date().toISOString(), session, goal: normalized.goal })
|
|
171
|
+
events.push({ op: 'record', at: new Date().toISOString(), session, facts: created.facts, findings: created.findings, assets: created.assets, edges: created.edges })
|
|
159
172
|
await appendText(shell, file, events.map(event => JSON.stringify(event)).join('\n') + '\n', exec)
|
|
160
173
|
return JSON.stringify({
|
|
161
174
|
tool: 'eng_record',
|
|
@@ -165,12 +178,13 @@ export function apply(ctx) {
|
|
|
165
178
|
created: { facts: created.facts.length, findings: created.findings.length, assets: created.assets.length, edges: created.edges.length },
|
|
166
179
|
ids: { facts: created.facts.map(row => row.id), findings: created.findings.map(row => row.id), assets: created.assets.map(row => row.id) },
|
|
167
180
|
goal: normalized.goal === null ? null : normalized.goal.target,
|
|
181
|
+
session,
|
|
168
182
|
},
|
|
169
183
|
confidence: 'observed',
|
|
170
184
|
limitations: [
|
|
171
|
-
'记录是 append-only
|
|
172
|
-
'id 是确定性 <kind>-<n
|
|
173
|
-
'ledger 在 <workspace>/.fly-engagement/events.jsonl ——
|
|
185
|
+
'记录是 append-only 的:写错要改请换会话/工作区重来(v1 不做改写/删除)',
|
|
186
|
+
'id 是确定性 <kind>-<n>(按账本的记录次序),跨调用可引用;同一次调用内不能互相引用(先父后子)',
|
|
187
|
+
'ledger 在 <workspace>/.fly-engagement/events.jsonl —— 一个工作区一条账本,每行带 session 归属',
|
|
174
188
|
],
|
|
175
189
|
}, null, 2)
|
|
176
190
|
},
|
|
@@ -181,6 +195,8 @@ export function apply(ctx) {
|
|
|
181
195
|
description: 'Read the engagement projection: goal, nodes, edges, counts, and the window bookkeeping (dropped/refused/truncated).',
|
|
182
196
|
parameters: {
|
|
183
197
|
workspace: workspaceParam,
|
|
198
|
+
session: sessionParam,
|
|
199
|
+
scope: scopeParam,
|
|
184
200
|
window: { type: 'number', description: 'Node window per table (default 200)' },
|
|
185
201
|
},
|
|
186
202
|
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
|
|
@@ -188,16 +204,17 @@ export function apply(ctx) {
|
|
|
188
204
|
if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_state 需要 workspace(绝对路径)')
|
|
189
205
|
const windowSize = Math.min(2000, Math.max(1, Math.round(Number(args.window ?? 200) || 200)))
|
|
190
206
|
const file = ledgerPath(args.workspace)
|
|
191
|
-
const view =
|
|
207
|
+
const view = scopeView(foldEvents((await readText(shell, file, exec)).split('\n')), { session: resolveSession(args, exec), scope: args.scope }, windowSize)
|
|
192
208
|
return JSON.stringify({
|
|
193
209
|
tool: 'eng_state',
|
|
194
210
|
target: file,
|
|
195
|
-
operation: 'fold the workspace ledger into the
|
|
211
|
+
operation: 'fold the workspace ledger into the ' + view.scope + ' projection (window=' + windowSize + ')',
|
|
196
212
|
observed: view,
|
|
197
213
|
confidence: 'observed',
|
|
198
214
|
limitations: [
|
|
199
215
|
'投影是**窗口视图**:每张表只留最新 ' + windowSize + ' 条;dropped=' + view.dropped + '(被逐出)refused=' + view.refused + '(引用不存在的 id 被拒,累计)',
|
|
200
216
|
view.truncated ? '⚠️ truncated=true:被逐出的记录不在这个视图里 —— 完整记录以 eng_report(读全量)为准' : '这次没有截断(每张表都在窗口内)',
|
|
217
|
+
view.scope === 'session' ? '当前是会话视图;scope=workspace 可看整个工作区(workspaceCounts 已给出全量计数)' : '当前是整个工作区视图:多会话的记录合并在这一份里',
|
|
201
218
|
'ledger 不存在 ⇒ 空 projection(还没开始记录,不是错误)',
|
|
202
219
|
],
|
|
203
220
|
}, null, 2)
|
|
@@ -207,24 +224,81 @@ export function apply(ctx) {
|
|
|
207
224
|
ctx.tools.register(defineTool({
|
|
208
225
|
name: 'eng_report',
|
|
209
226
|
description: 'Render the engagement report as Markdown (goal + authorization, assets, facts with source tools, findings with reproducible steps, counts).',
|
|
210
|
-
parameters: { workspace: workspaceParam },
|
|
227
|
+
parameters: { workspace: workspaceParam, session: sessionParam, scope: scopeParam },
|
|
211
228
|
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
|
|
212
229
|
async execute(args, exec) {
|
|
213
230
|
if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_report 需要 workspace(绝对路径)')
|
|
214
231
|
const file = ledgerPath(args.workspace)
|
|
215
232
|
const state = foldEvents((await readText(shell, file, exec)).split('\n'))
|
|
233
|
+
const view = scopeView(state, { session: resolveSession(args, exec), scope: args.scope }, 2000)
|
|
216
234
|
return JSON.stringify({
|
|
217
235
|
tool: 'eng_report',
|
|
218
236
|
target: file,
|
|
219
|
-
operation: 'render the
|
|
220
|
-
observed: { markdown: renderReport(args.workspace,
|
|
237
|
+
operation: 'render the ' + view.scope + ' engagement report as Markdown (no window)',
|
|
238
|
+
observed: { markdown: renderReport(args.workspace, view), counts: view.counts, workspaceCounts: view.workspaceCounts, refused: view.refused, scope: view.scope, session: view.session },
|
|
221
239
|
confidence: 'observed',
|
|
222
240
|
limitations: [
|
|
223
|
-
'
|
|
241
|
+
'这份报告读的是**当前作用域**的全量 ledger(不经窗口)—— 面板上的图是窗口视图,两者条数可能不同',
|
|
224
242
|
'findings 的复现步骤原样列出(不重新编号内容、不排序)',
|
|
225
243
|
'ledger 写在 <workspace>/.fly-engagement/events.jsonl;报告可以复制走,记录仍在工作区里',
|
|
226
244
|
],
|
|
227
245
|
}, null, 2)
|
|
228
246
|
},
|
|
229
247
|
}))
|
|
248
|
+
|
|
249
|
+
ctx.tools.register(defineTool({
|
|
250
|
+
name: 'eng_adopt',
|
|
251
|
+
description: 'Claim v1 records that have no session (legacy, session=null) into the calling session. Records already owned by another session are skipped, never stolen.',
|
|
252
|
+
parameters: {
|
|
253
|
+
workspace: workspaceParam,
|
|
254
|
+
session: sessionParam,
|
|
255
|
+
all: { type: 'boolean', description: 'Adopt every unowned node (and the unowned goal). Default false.' },
|
|
256
|
+
ids: { type: 'array', items: { type: 'string' }, description: 'Explicit node ids to adopt (facts/findings/assets). Ignored when all=true.' },
|
|
257
|
+
goal: { type: 'boolean', description: 'Also adopt the unowned goal when this session has none.' },
|
|
258
|
+
},
|
|
259
|
+
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
|
|
260
|
+
async execute(args, exec) {
|
|
261
|
+
if (typeof args.workspace !== 'string' || args.workspace.trim() === '') throw argumentError('eng_adopt 需要 workspace(绝对路径)')
|
|
262
|
+
const session = resolveSession(args, exec)
|
|
263
|
+
if (session === null) throw argumentError('eng_adopt 需要会话身份:显式传 session,或由 agent 调用(exec.agent.id)')
|
|
264
|
+
const file = ledgerPath(args.workspace)
|
|
265
|
+
const state = foldEvents((await readText(shell, file, exec)).split('\n'))
|
|
266
|
+
const unowned = [...state.facts, ...state.findings, ...state.assets].filter(row => (row.session ?? null) === null)
|
|
267
|
+
const ids = args.all === true ? unowned.map(row => row.id) : (Array.isArray(args.ids) ? args.ids : [])
|
|
268
|
+
const wantGoal = args.goal === true || args.all === true
|
|
269
|
+
if (ids.length === 0 && !wantGoal) throw argumentError('eng_adopt 没有可认领的东西(ids 为空且 goal 未请求)')
|
|
270
|
+
if (args.all !== true && unowned.length > 0 && ids.every(id => !unowned.some(row => row.id === id)) && !wantGoal) {
|
|
271
|
+
throw argumentError('给出的 ids 里没有未归属记录(已归属其他会话的不能认领)')
|
|
272
|
+
}
|
|
273
|
+
const event = { op: 'adopt', at: new Date().toISOString(), session, ids, goal: wantGoal }
|
|
274
|
+
await appendText(shell, file, JSON.stringify(event) + '\n', exec)
|
|
275
|
+
const after = foldEvents((await readText(shell, file, exec)).split('\n'))
|
|
276
|
+
const view = scopeView(after, { session, scope: 'session' }, 2000)
|
|
277
|
+
const delta = (before, afterRows) => before - afterRows
|
|
278
|
+
return JSON.stringify({
|
|
279
|
+
tool: 'eng_adopt',
|
|
280
|
+
target: file,
|
|
281
|
+
operation: 'append an adopt event (reassign unowned v1 records to this session)',
|
|
282
|
+
observed: {
|
|
283
|
+
session,
|
|
284
|
+
adopted: {
|
|
285
|
+
facts: delta(state.facts.filter(row => (row.session ?? null) === null).length, after.facts.filter(row => (row.session ?? null) === null).length),
|
|
286
|
+
findings: delta(state.findings.filter(row => (row.session ?? null) === null).length, after.findings.filter(row => (row.session ?? null) === null).length),
|
|
287
|
+
assets: delta(state.assets.filter(row => (row.session ?? null) === null).length, after.assets.filter(row => (row.session ?? null) === null).length),
|
|
288
|
+
edges: delta(state.edges.filter(row => (row.session ?? null) === null).length, after.edges.filter(row => (row.session ?? null) === null).length),
|
|
289
|
+
goal: delta(state.goals.filter(row => row.session === null).length, after.goals.filter(row => row.session === null).length),
|
|
290
|
+
},
|
|
291
|
+
counts: view.counts,
|
|
292
|
+
legacy: view.legacy,
|
|
293
|
+
skipped: after.skipped,
|
|
294
|
+
},
|
|
295
|
+
confidence: 'observed',
|
|
296
|
+
limitations: [
|
|
297
|
+
'认领是 append-only 的:写一条 adopt 事件,不改历史行',
|
|
298
|
+
'已归属其他会话的记录一律跳过(计入 skipped),不会被抢',
|
|
299
|
+
'未归属 goal 只会在本会话还没有 goal 时被认领',
|
|
300
|
+
],
|
|
301
|
+
}, null, 2)
|
|
302
|
+
},
|
|
303
|
+
}))
|
|
230
304
|
}
|