@dsh-fly/dsh-plugin-fly-engagement 0.1.5 → 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 +361 -55
- 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. */
|
|
@@ -36,9 +36,20 @@ const PANEL_CSS = `/*
|
|
|
36
36
|
之间多出一条黑边、发光顶不到边"(用户判词)。宿主里每一条声明都自带 var() 兜底,
|
|
37
37
|
不需要这里的 :root。留注释备查,mock 页仍按原样式表渲染。 */
|
|
38
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 兜住窄抽屉下的横向溢出。 */
|
|
39
44
|
.fly-eg-panel {
|
|
40
45
|
--fly-rail: 8px;
|
|
46
|
+
box-sizing: border-box;
|
|
47
|
+
flex: 0 1 auto;
|
|
48
|
+
min-height: 0;
|
|
41
49
|
width: 760px;
|
|
50
|
+
max-width: 100%;
|
|
51
|
+
overflow-y: auto;
|
|
52
|
+
overscroll-behavior: contain;
|
|
42
53
|
border-radius: 12px;
|
|
43
54
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
44
55
|
background:
|
|
@@ -79,8 +90,12 @@ const PANEL_CSS = `/*
|
|
|
79
90
|
/* ── 表 ── */
|
|
80
91
|
.fly-eg-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
81
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); }
|
|
82
|
-
|
|
83
|
-
|
|
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; }
|
|
84
99
|
.fly-eg-src { font-size: 11px; color: var(--dsw-alias-fg-muted); }
|
|
85
100
|
.fly-eg-src b { color: var(--dsw-alias-fg-secondary); font-weight: 500; }
|
|
86
101
|
.fly-eg-sev { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 999px; display: inline-block; }
|
|
@@ -89,21 +104,54 @@ const PANEL_CSS = `/*
|
|
|
89
104
|
.fly-eg-sev.medium { color: #1a1506; background: var(--fly-medium); }
|
|
90
105
|
.fly-eg-sev.low { color: #061019; background: var(--fly-low); }
|
|
91
106
|
.fly-eg-sev.info { color: #0b1017; background: var(--fly-info); }
|
|
92
|
-
|
|
93
|
-
.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; }
|
|
94
119
|
|
|
95
120
|
/* ── 图区:静态分层 + 边胶囊 + 只读(借 xyflow 的形状,不引入拖拽) ── */
|
|
96
|
-
.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; }
|
|
97
124
|
.fly-eg-graph-hint { position: absolute; left: 8px; bottom: 6px; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
|
|
98
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; }
|
|
99
|
-
|
|
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 又以更高特异性收尾。 */
|
|
100
130
|
.fly-eg-node.is-finding { border-color: rgba(255, 155, 82, 0.55); background: rgba(255, 155, 82, 0.14); color: #ffd7b0; }
|
|
101
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; }
|
|
102
139
|
.fly-eg-edge { position: absolute; height: 1.5px; background: rgba(240, 165, 90, 0.55); transform-origin: 0 50%; }
|
|
103
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); }
|
|
104
141
|
|
|
105
142
|
/* ── 报告区 ── */
|
|
106
|
-
.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; }
|
|
107
155
|
.fly-eg-report-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
108
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); }
|
|
109
157
|
|
|
@@ -124,6 +172,16 @@ const PANEL_CSS = `/*
|
|
|
124
172
|
.fly-eg-group > summary .fly-eg-count { margin-left: auto; font-size: 10.5px; color: var(--dsw-alias-fg-muted); }
|
|
125
173
|
.fly-eg-group table { margin: 0 0 2px; }
|
|
126
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
|
+
|
|
127
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); }
|
|
128
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; }
|
|
129
187
|
.fly-eg-empty { margin: 14px 0; color: var(--dsw-alias-fg-muted); font-size: 11.5px; }
|
|
@@ -133,7 +191,145 @@ async function getJson(path) {
|
|
|
133
191
|
if (!response.ok) throw new Error(path + " → HTTP " + response.status);
|
|
134
192
|
return await response.json();
|
|
135
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
|
+
}
|
|
136
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
|
+
});
|
|
137
333
|
const byLayer = /* @__PURE__ */ new Map();
|
|
138
334
|
for (const node of nodes) {
|
|
139
335
|
const list = byLayer.get(node.layer) ?? [];
|
|
@@ -141,65 +337,152 @@ function Graph({ nodes, edges, kind }) {
|
|
|
141
337
|
byLayer.set(node.layer, list);
|
|
142
338
|
}
|
|
143
339
|
const layers = [...byLayer.keys()].sort((a, b) => a - b);
|
|
144
|
-
const
|
|
145
|
-
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;
|
|
146
352
|
const xOf = /* @__PURE__ */ new Map();
|
|
147
|
-
let cursor =
|
|
353
|
+
let cursor = 16;
|
|
148
354
|
for (const layer of layers) {
|
|
149
|
-
|
|
150
|
-
|
|
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;
|
|
151
358
|
}
|
|
152
|
-
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));
|
|
153
362
|
const yOf = /* @__PURE__ */ new Map();
|
|
154
363
|
for (const layer of layers) {
|
|
155
364
|
const list = byLayer.get(layer) ?? [];
|
|
156
365
|
list.forEach((node, index) => yOf.set(node.id, height / (list.length + 1) * (index + 1)));
|
|
157
366
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
] }),
|
|
174
408
|
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-graph-hint", children: "折线走层间间隙 · 静态分层 · 只读" })
|
|
175
409
|
] });
|
|
176
410
|
}
|
|
177
|
-
function Panel() {
|
|
178
|
-
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");
|
|
179
417
|
const [state, setState] = react.useState(null);
|
|
180
418
|
const [error, setError] = react.useState("");
|
|
181
419
|
const [view, setView] = react.useState("table");
|
|
182
420
|
const [report, setReport] = react.useState("");
|
|
183
|
-
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) => {
|
|
184
433
|
if (target.trim() === "") return;
|
|
185
434
|
setError("");
|
|
435
|
+
const query = "workspace=" + encodeURIComponent(target) + "&session=" + encodeURIComponent(session) + "&scope=" + mode;
|
|
186
436
|
try {
|
|
187
437
|
const [next, rendered] = await Promise.all([
|
|
188
|
-
getJson(BASE + "/state?
|
|
189
|
-
getJson(BASE + "/report?
|
|
438
|
+
getJson(BASE + "/state?" + query),
|
|
439
|
+
getJson(BASE + "/report?" + query).catch(() => ({ markdown: "" }))
|
|
190
440
|
]);
|
|
191
441
|
setState(next);
|
|
192
442
|
setReport(rendered.markdown);
|
|
193
|
-
localStorage.setItem(STORE_KEY, target);
|
|
194
443
|
} catch (failure) {
|
|
195
|
-
|
|
444
|
+
const message = String(failure.message ?? failure);
|
|
445
|
+
setError(message.includes("HTTP 404") ? "宿主没有注册 " + BASE + " 路由(面板行未激活)· 重启 DSH 或重载 profile 后再试 · " + message : message);
|
|
196
446
|
setState(null);
|
|
197
447
|
}
|
|
198
|
-
}, []);
|
|
448
|
+
}, [session]);
|
|
199
449
|
react.useEffect(() => {
|
|
200
|
-
void load(workspace);
|
|
201
|
-
}, [load, workspace]);
|
|
450
|
+
void load(workspace, scope);
|
|
451
|
+
}, [load, workspace, scope]);
|
|
452
|
+
react.useEffect(() => {
|
|
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
|
+
};
|
|
202
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;
|
|
203
486
|
const groups = react.useMemo(() => {
|
|
204
487
|
const map = /* @__PURE__ */ new Map();
|
|
205
488
|
for (const fact of projection?.facts ?? []) {
|
|
@@ -212,20 +495,26 @@ function Panel() {
|
|
|
212
495
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-head", children: [
|
|
213
496
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-title", children: [
|
|
214
497
|
/* @__PURE__ */ jsxRuntime.jsx("h1", { children: "Engagement" }),
|
|
498
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-scope-badge", children: session === "" ? "未绑定会话" : "会话 " + session.slice(0, 8) }),
|
|
215
499
|
projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-badge", children: projection.goal.target })
|
|
216
500
|
] }),
|
|
217
|
-
/* @__PURE__ */ jsxRuntime.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|
|
226
514
|
}
|
|
227
|
-
|
|
228
|
-
|
|
515
|
+
),
|
|
516
|
+
override.trim() === "" && liveCwd !== void 0 && liveCwd !== "" ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-hint", children: "跟随会话 cwd · 输入可覆盖" }) : null
|
|
517
|
+
] }),
|
|
229
518
|
projection?.goal === null || projection === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
230
519
|
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-meta", children: /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
|
|
231
520
|
"objective: ",
|
|
@@ -240,6 +529,20 @@ function Panel() {
|
|
|
240
529
|
] }),
|
|
241
530
|
error !== "" ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fly-eg-error", children: error }) : null,
|
|
242
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,
|
|
243
546
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-counters", children: [
|
|
244
547
|
/* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fly-eg-counter", children: [
|
|
245
548
|
"facts",
|
|
@@ -286,7 +589,7 @@ function Panel() {
|
|
|
286
589
|
/* @__PURE__ */ jsxRuntime.jsx("td", { className: "fly-eg-id", children: finding.id }),
|
|
287
590
|
/* @__PURE__ */ jsxRuntime.jsx("td", { children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fly-eg-sev " + (finding.severity ?? "info"), children: finding.severity ?? "info" }) }),
|
|
288
591
|
/* @__PURE__ */ jsxRuntime.jsx("td", { children: finding.title }),
|
|
289
|
-
/* @__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)) }) })
|
|
290
593
|
] }, finding.id)) }) })
|
|
291
594
|
] }),
|
|
292
595
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
|
|
@@ -306,8 +609,11 @@ function Panel() {
|
|
|
306
609
|
] }, tool))
|
|
307
610
|
] }),
|
|
308
611
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
|
|
309
|
-
/* @__PURE__ */ jsxRuntime.
|
|
310
|
-
|
|
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) })
|
|
311
617
|
] })
|
|
312
618
|
] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
313
619
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fly-eg-section", children: [
|
|
@@ -330,7 +636,7 @@ function Panel() {
|
|
|
330
636
|
Graph,
|
|
331
637
|
{
|
|
332
638
|
kind: "parent",
|
|
333
|
-
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" })),
|
|
334
640
|
edges: projection.edges.filter((edge) => edge.kind === "parent")
|
|
335
641
|
}
|
|
336
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
|
}
|