dsh-d365-kit 0.34.1 → 0.34.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +318 -308
- package/CHANGELOG.zh-CN.md +317 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/tool-reports.js
CHANGED
|
@@ -1,299 +1,299 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_reports` — one place to see what the other tools produced, how much
|
|
3
|
-
* space `.d365` is using, and to apply the retention policy by hand.
|
|
4
|
-
*
|
|
5
|
-
* Cleanup always plans first (`dryRun` defaults to true) and never touches
|
|
6
|
-
* waivers, pending plans, config or secrets.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
10
|
-
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
11
|
-
import { loadConfig } from './config.js'
|
|
12
|
-
import { D365Error } from './errors.js'
|
|
13
|
-
import { dataRoot } from './snapshot.js'
|
|
14
|
-
import { readHistory } from './contract-history.js'
|
|
15
|
-
import { readWaivers } from './waivers.js'
|
|
16
|
-
import {
|
|
17
|
-
applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary,
|
|
18
|
-
} from './retention.js'
|
|
19
|
-
|
|
20
|
-
const CLASS_IDS = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all']
|
|
21
|
-
|
|
22
|
-
async function listFiles(directory, limit) {
|
|
23
|
-
let names = []
|
|
24
|
-
try {
|
|
25
|
-
names = await readdir(directory)
|
|
26
|
-
} catch {
|
|
27
|
-
return []
|
|
28
|
-
}
|
|
29
|
-
const rows = []
|
|
30
|
-
for (const name of names) {
|
|
31
|
-
try {
|
|
32
|
-
const info = await stat(join(directory, name))
|
|
33
|
-
if (!info.isFile()) continue
|
|
34
|
-
rows.push({ name, size: info.size, modifiedAt: info.mtime.toISOString() })
|
|
35
|
-
} catch {
|
|
36
|
-
// A file that vanished between readdir and stat is simply not listed.
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
rows.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
|
|
40
|
-
return rows.slice(0, limit)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const VIEW_DEFAULT_LIMIT = 60
|
|
44
|
-
const VIEW_MAX_LIMIT = 2000
|
|
45
|
-
const VIEW_WRAP_DEFAULT = 400
|
|
46
|
-
|
|
47
|
-
/** @returns true when `file` stays inside `root` (no `..` escape, no absolute jump). */
|
|
48
|
-
function insideRoot(root, file) {
|
|
49
|
-
const rel = relative(root, file)
|
|
50
|
-
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Locate an artifact the model asked to read.
|
|
55
|
-
*
|
|
56
|
-
* Only files under `.d365` are reachable: an absolute path that points
|
|
57
|
-
* elsewhere is refused rather than silently read, because the tool's contract
|
|
58
|
-
* is "show me what this plugin wrote", not "read any file".
|
|
59
|
-
* @returns `{ file, size }`
|
|
60
|
-
*/
|
|
61
|
-
async function resolveArtifact(config, name) {
|
|
62
|
-
const wanted = String(name ?? '').trim()
|
|
63
|
-
if (!wanted) {
|
|
64
|
-
throw new D365Error('view 需要 file', {
|
|
65
|
-
hint: '先用 action=list 看有哪些产物;file 可以是 .d365 下的相对路径(如 query/xxx.json)或文件名。',
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
const root = resolve(dataRoot(config))
|
|
69
|
-
const candidates = isAbsolute(wanted)
|
|
70
|
-
? [resolve(wanted)]
|
|
71
|
-
: [resolve(root, wanted), ...['query', 'reports', 'diffs', 'changes'].map((dir) => resolve(root, dir, wanted))]
|
|
72
|
-
for (const candidate of candidates) {
|
|
73
|
-
if (!insideRoot(root, candidate)) continue
|
|
74
|
-
try {
|
|
75
|
-
const info = await stat(candidate)
|
|
76
|
-
if (info.isFile()) return { file: candidate, size: info.size }
|
|
77
|
-
} catch {
|
|
78
|
-
// Not here; the next candidate may be.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
throw new D365Error(`找不到产物:${wanted}`, {
|
|
82
|
-
code: 'artifact-not-found',
|
|
83
|
-
hint: '只在 .d365 目录里查找。用 action=list 看现有文件;绝对路径也必须落在这个目录内。',
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Render a window of one artifact.
|
|
89
|
-
*
|
|
90
|
-
* JSON written by the query tools is one very long line, so it is expanded by
|
|
91
|
-
* default; `match` narrows to the lines that mention a string, which is the
|
|
92
|
-
* only practical way to find one GUID in a 500 KB result.
|
|
93
|
-
*/
|
|
94
|
-
async function renderView(config, args) {
|
|
95
|
-
const { file, size } = await resolveArtifact(config, args.file)
|
|
96
|
-
const raw = await readFile(file, 'utf8')
|
|
97
|
-
const isJson = file.toLowerCase().endsWith('.json')
|
|
98
|
-
const pretty = args.pretty ?? isJson
|
|
99
|
-
let text = raw
|
|
100
|
-
if (pretty && isJson) {
|
|
101
|
-
try {
|
|
102
|
-
text = JSON.stringify(JSON.parse(raw), null, 2)
|
|
103
|
-
} catch {
|
|
104
|
-
// Malformed or truncated JSON: show what is on disk instead of failing.
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const allLines = text.split(/\r?\n/)
|
|
109
|
-
const match = String(args.match ?? '').trim()
|
|
110
|
-
const numbered = allLines.map((line, index) => ({ line, number: index + 1 }))
|
|
111
|
-
const filtered = match
|
|
112
|
-
? numbered.filter((item) => item.line.toLowerCase().includes(match.toLowerCase()))
|
|
113
|
-
: numbered
|
|
114
|
-
|
|
115
|
-
const offset = Number.isFinite(args.offset) && args.offset > 0 ? Math.floor(args.offset) : 0
|
|
116
|
-
const limit = Math.min(Math.max(Number.isFinite(args.limit) ? Math.floor(args.limit) : VIEW_DEFAULT_LIMIT, 1), VIEW_MAX_LIMIT)
|
|
117
|
-
const wrap = args.wrap === 0 ? 0 : Math.min(Math.max(Number.isFinite(args.wrap) ? Math.floor(args.wrap) : VIEW_WRAP_DEFAULT, 40), 4000)
|
|
118
|
-
const window = filtered.slice(offset, offset + limit)
|
|
119
|
-
|
|
120
|
-
const header = [
|
|
121
|
-
`文件:${file}`,
|
|
122
|
-
`大小:${formatBytes(size)} · 行数:${allLines.length}${pretty && isJson ? '(JSON 已展开)' : ''}${match ? ` · 匹配 "${match}":${filtered.length} 行` : ''}`,
|
|
123
|
-
]
|
|
124
|
-
const shownTo = offset + window.length
|
|
125
|
-
const remaining = filtered.length - shownTo
|
|
126
|
-
header.push(`第 ${offset + 1}–${shownTo} 行${remaining > 0 ? `(还有 ${remaining} 行,调 offset: ${shownTo} 继续)` : ''}`)
|
|
127
|
-
|
|
128
|
-
const body = []
|
|
129
|
-
for (const item of window) {
|
|
130
|
-
const pieces = wrap > 0 ? item.line.match(new RegExp(`[\\s\\S]{1,${wrap}}`, 'g')) ?? [''] : [item.line]
|
|
131
|
-
body.push(`${String(item.number).padStart(6)}│ ${pieces[0]}`)
|
|
132
|
-
for (const piece of pieces.slice(1)) body.push(`${' '.repeat(6)}│ ${piece}`)
|
|
133
|
-
}
|
|
134
|
-
if (!window.length) body.push(match ? '(没有匹配的行)' : '(文件为空)')
|
|
135
|
-
|
|
136
|
-
return { text: [...header, '', ...body].join('\n'), file }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function storageLines(summary) {
|
|
140
|
-
const lines = [`占用合计:${formatBytes(summary.totalBytes)}${summary.overLimit ? `(超过 ${summary.warnMegabytes} MB,建议清理)` : ''}`, '']
|
|
141
|
-
for (const row of summary.classes) {
|
|
142
|
-
lines.push(`- ${row.label}(${row.id}):${row.count} 个 · ${formatBytes(row.bytes)} · 保留 ${row.keep} 个 / ${row.maxAgeDays} 天${row.hint ? ` · ${row.hint}` : ''}`)
|
|
143
|
-
}
|
|
144
|
-
return lines
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function planLines(plan) {
|
|
148
|
-
const lines = [`将删除 ${plan.totalFiles} 个文件,释放 ${formatBytes(plan.totalBytes)}`, '']
|
|
149
|
-
for (const row of plan.classes) {
|
|
150
|
-
if (!row.remove.length) {
|
|
151
|
-
lines.push(`- ${row.label}:无需清理(${row.count} 个,保留 ${row.keep} 个 / ${row.maxAgeDays} 天)`)
|
|
152
|
-
continue
|
|
153
|
-
}
|
|
154
|
-
lines.push(`- ${row.label}:删除 ${row.remove.length} 个 · ${formatBytes(row.bytes)}`)
|
|
155
|
-
for (const item of row.remove.slice(0, 5)) {
|
|
156
|
-
lines.push(` · ${item.name}(${item.ageDays} 天前,${formatBytes(item.bytes)})`)
|
|
157
|
-
}
|
|
158
|
-
if (row.remove.length > 5) lines.push(` · … 其余 ${row.remove.length - 5} 个`)
|
|
159
|
-
}
|
|
160
|
-
return lines
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function register(ctx, state) {
|
|
164
|
-
const dispose = ctx.tools.register({
|
|
165
|
-
name: 'd365_reports',
|
|
166
|
-
description:
|
|
167
|
-
'查看本机数据占用与产物(.d365/reports、query 等),并按保留策略清理或压缩历史产物。cleanup 默认只预演(dryRun),确认后传 dryRun=false 才真正删除。',
|
|
168
|
-
parameters: {
|
|
169
|
-
type: 'object',
|
|
170
|
-
additionalProperties: false,
|
|
171
|
-
properties: {
|
|
172
|
-
action: {
|
|
173
|
-
type: 'string',
|
|
174
|
-
enum: ['list', 'cleanup', 'compact', 'view'],
|
|
175
|
-
description: 'list:看占用与产物;view:读回一个产物文件(分页/过滤);cleanup:按策略清理;compact:把已有快照压成紧凑 JSON。默认 list。',
|
|
176
|
-
},
|
|
177
|
-
file: {
|
|
178
|
-
type: 'string',
|
|
179
|
-
description: 'view 的文件:.d365 下的相对路径(如 query/2026-11-06T01-36-47-274Z-layers.json)或文件名。',
|
|
180
|
-
},
|
|
181
|
-
offset: { type: 'number', description: 'view 从第几行开始(0 基),默认 0。' },
|
|
182
|
-
match: { type: 'string', description: 'view 只显示包含这个字符串的行(不区分大小写)。在大 JSON 里找一条 GUID 用这个。' },
|
|
183
|
-
wrap: { type: 'number', description: 'view 每行最多多少字符,超长折行;0 表示不折行。默认 400。' },
|
|
184
|
-
pretty: { type: 'boolean', description: 'view 是否把 JSON 缩进展开,默认对 .json 为 true。' },
|
|
185
|
-
target: {
|
|
186
|
-
type: 'string',
|
|
187
|
-
enum: CLASS_IDS,
|
|
188
|
-
description: 'cleanup 的范围:某一类或 all,默认 all。',
|
|
189
|
-
},
|
|
190
|
-
dryRun: {
|
|
191
|
-
type: 'boolean',
|
|
192
|
-
description: 'cleanup 默认 true,只预演不删除;确认要删时传 false。',
|
|
193
|
-
},
|
|
194
|
-
limit: { type: 'number', description: 'list 每类最多列多少条,默认 15。' },
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
output: {
|
|
198
|
-
schema: {
|
|
199
|
-
type: 'object',
|
|
200
|
-
additionalProperties: false,
|
|
201
|
-
required: ['schemaVersion', 'action', 'text'],
|
|
202
|
-
properties: {
|
|
203
|
-
schemaVersion: { type: 'number' },
|
|
204
|
-
action: { type: 'string' },
|
|
205
|
-
text: { type: 'string' },
|
|
206
|
-
},
|
|
207
|
-
},
|
|
208
|
-
render: (args, value) => [{ type: 'text', text: value.text }],
|
|
209
|
-
},
|
|
210
|
-
async execute(args) {
|
|
211
|
-
const config = await loadConfig(state.rawConfig)
|
|
212
|
-
const action = args.action ?? 'list'
|
|
213
|
-
|
|
214
|
-
if (action === 'view') {
|
|
215
|
-
const view = await renderView(config, args)
|
|
216
|
-
return { schemaVersion: 1, action, text: view.text }
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (action === 'compact') {
|
|
220
|
-
const result = await compactAllSnapshots(config)
|
|
221
|
-
const summary = await storageSummary(config)
|
|
222
|
-
const lines = [
|
|
223
|
-
`已压缩 ${result.snapshots} 份快照里的 ${result.files} 个文件,释放 ${formatBytes(result.savedBytes)}。`,
|
|
224
|
-
'',
|
|
225
|
-
...storageLines(summary),
|
|
226
|
-
]
|
|
227
|
-
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (action === 'cleanup') {
|
|
231
|
-
const target = CLASS_IDS.includes(args.target) ? args.target : 'all'
|
|
232
|
-
const dryRun = args.dryRun !== false
|
|
233
|
-
const plan = await planCleanup(config, { target })
|
|
234
|
-
if (dryRun) {
|
|
235
|
-
const lines = [`清理预演(${target}):尚未删除任何文件。`, '', ...planLines(plan)]
|
|
236
|
-
lines.push('', '确认要删除时,把 dryRun 设为 false 再调用一次。')
|
|
237
|
-
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
238
|
-
}
|
|
239
|
-
const applied = await applyCleanup(config, plan)
|
|
240
|
-
const summary = await storageSummary(config)
|
|
241
|
-
const lines = [
|
|
242
|
-
`已删除 ${applied.deleted.length} 个文件,释放 ${formatBytes(applied.bytes)}。`,
|
|
243
|
-
...(applied.failures.length ? ['', `失败 ${applied.failures.length} 个:`, ...applied.failures.map((item) => `- ${item.name}:${item.reason}`)] : []),
|
|
244
|
-
'',
|
|
245
|
-
...storageLines(summary),
|
|
246
|
-
]
|
|
247
|
-
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const limit = Number.isFinite(args.limit) ? Math.min(Math.max(args.limit, 1), 100) : 15
|
|
251
|
-
const root = dataRoot(config)
|
|
252
|
-
const reports = await listFiles(join(root, 'reports'), limit)
|
|
253
|
-
const queries = await listFiles(join(root, 'query'), limit)
|
|
254
|
-
const summary = await storageSummary(config)
|
|
255
|
-
|
|
256
|
-
let waiverCount = 0
|
|
257
|
-
try {
|
|
258
|
-
const document = await readWaivers(config)
|
|
259
|
-
waiverCount = (document.waivers ?? []).length
|
|
260
|
-
} catch {
|
|
261
|
-
waiverCount = 0
|
|
262
|
-
}
|
|
263
|
-
let lastContractRun = ''
|
|
264
|
-
try {
|
|
265
|
-
const history = await readHistory(config)
|
|
266
|
-
const last = history.runs?.[history.runs.length - 1]
|
|
267
|
-
if (last) {
|
|
268
|
-
lastContractRun = `${last.at}(${last.source} → ${(last.targets ?? []).join(', ')},阻断 ${last.blocker ?? 0} / 警告 ${last.warning ?? 0},豁免 ${last.waived ?? 0})`
|
|
269
|
-
}
|
|
270
|
-
} catch {
|
|
271
|
-
lastContractRun = ''
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const lines = ['本机数据', '', ...storageLines(summary), '']
|
|
275
|
-
// Totals come from the storage summary, not from the truncated listing:
|
|
276
|
-
// "25 个" next to three bullets looked like a bug.
|
|
277
|
-
const classCount = (id) => summary.classes.find((item) => item.id === id)?.count ?? 0
|
|
278
|
-
const reportTotal = classCount('reports')
|
|
279
|
-
lines.push(`报告(.d365/reports):${reportTotal} 个${reportTotal > reports.length ? `,下面是最近 ${reports.length} 个` : ''}`)
|
|
280
|
-
for (const row of reports) {
|
|
281
|
-
lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB · ${row.modifiedAt}`)
|
|
282
|
-
}
|
|
283
|
-
lines.push('')
|
|
284
|
-
const queryTotal = classCount('query')
|
|
285
|
-
const shownQueries = queries.slice(0, 8)
|
|
286
|
-
lines.push(`查询留证(.d365/query):${queryTotal} 个${queryTotal > shownQueries.length ? `,下面是最近 ${shownQueries.length} 个` : ''}`)
|
|
287
|
-
for (const row of shownQueries) {
|
|
288
|
-
lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB`)
|
|
289
|
-
}
|
|
290
|
-
lines.push('')
|
|
291
|
-
lines.push(`已登记豁免:${waiverCount} 条`)
|
|
292
|
-
lines.push(`最近一次契约检查:${lastContractRun || '还没有跑过'}`)
|
|
293
|
-
lines.push('')
|
|
294
|
-
lines.push('要读回某个产物(而不是只看文件名),用 action=view 加 file;大文件配 match 过滤、offset/limit 翻页。')
|
|
295
|
-
return { schemaVersion: 1, action: 'list', text: lines.join('\n') }
|
|
296
|
-
},
|
|
297
|
-
})
|
|
298
|
-
return dispose
|
|
299
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_reports` — one place to see what the other tools produced, how much
|
|
3
|
+
* space `.d365` is using, and to apply the retention policy by hand.
|
|
4
|
+
*
|
|
5
|
+
* Cleanup always plans first (`dryRun` defaults to true) and never touches
|
|
6
|
+
* waivers, pending plans, config or secrets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
10
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
11
|
+
import { loadConfig } from './config.js'
|
|
12
|
+
import { D365Error } from './errors.js'
|
|
13
|
+
import { dataRoot } from './snapshot.js'
|
|
14
|
+
import { readHistory } from './contract-history.js'
|
|
15
|
+
import { readWaivers } from './waivers.js'
|
|
16
|
+
import {
|
|
17
|
+
applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary,
|
|
18
|
+
} from './retention.js'
|
|
19
|
+
|
|
20
|
+
const CLASS_IDS = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all']
|
|
21
|
+
|
|
22
|
+
async function listFiles(directory, limit) {
|
|
23
|
+
let names = []
|
|
24
|
+
try {
|
|
25
|
+
names = await readdir(directory)
|
|
26
|
+
} catch {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
const rows = []
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
try {
|
|
32
|
+
const info = await stat(join(directory, name))
|
|
33
|
+
if (!info.isFile()) continue
|
|
34
|
+
rows.push({ name, size: info.size, modifiedAt: info.mtime.toISOString() })
|
|
35
|
+
} catch {
|
|
36
|
+
// A file that vanished between readdir and stat is simply not listed.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
rows.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
|
|
40
|
+
return rows.slice(0, limit)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const VIEW_DEFAULT_LIMIT = 60
|
|
44
|
+
const VIEW_MAX_LIMIT = 2000
|
|
45
|
+
const VIEW_WRAP_DEFAULT = 400
|
|
46
|
+
|
|
47
|
+
/** @returns true when `file` stays inside `root` (no `..` escape, no absolute jump). */
|
|
48
|
+
function insideRoot(root, file) {
|
|
49
|
+
const rel = relative(root, file)
|
|
50
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Locate an artifact the model asked to read.
|
|
55
|
+
*
|
|
56
|
+
* Only files under `.d365` are reachable: an absolute path that points
|
|
57
|
+
* elsewhere is refused rather than silently read, because the tool's contract
|
|
58
|
+
* is "show me what this plugin wrote", not "read any file".
|
|
59
|
+
* @returns `{ file, size }`
|
|
60
|
+
*/
|
|
61
|
+
async function resolveArtifact(config, name) {
|
|
62
|
+
const wanted = String(name ?? '').trim()
|
|
63
|
+
if (!wanted) {
|
|
64
|
+
throw new D365Error('view 需要 file', {
|
|
65
|
+
hint: '先用 action=list 看有哪些产物;file 可以是 .d365 下的相对路径(如 query/xxx.json)或文件名。',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
const root = resolve(dataRoot(config))
|
|
69
|
+
const candidates = isAbsolute(wanted)
|
|
70
|
+
? [resolve(wanted)]
|
|
71
|
+
: [resolve(root, wanted), ...['query', 'reports', 'diffs', 'changes'].map((dir) => resolve(root, dir, wanted))]
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
if (!insideRoot(root, candidate)) continue
|
|
74
|
+
try {
|
|
75
|
+
const info = await stat(candidate)
|
|
76
|
+
if (info.isFile()) return { file: candidate, size: info.size }
|
|
77
|
+
} catch {
|
|
78
|
+
// Not here; the next candidate may be.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new D365Error(`找不到产物:${wanted}`, {
|
|
82
|
+
code: 'artifact-not-found',
|
|
83
|
+
hint: '只在 .d365 目录里查找。用 action=list 看现有文件;绝对路径也必须落在这个目录内。',
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render a window of one artifact.
|
|
89
|
+
*
|
|
90
|
+
* JSON written by the query tools is one very long line, so it is expanded by
|
|
91
|
+
* default; `match` narrows to the lines that mention a string, which is the
|
|
92
|
+
* only practical way to find one GUID in a 500 KB result.
|
|
93
|
+
*/
|
|
94
|
+
async function renderView(config, args) {
|
|
95
|
+
const { file, size } = await resolveArtifact(config, args.file)
|
|
96
|
+
const raw = await readFile(file, 'utf8')
|
|
97
|
+
const isJson = file.toLowerCase().endsWith('.json')
|
|
98
|
+
const pretty = args.pretty ?? isJson
|
|
99
|
+
let text = raw
|
|
100
|
+
if (pretty && isJson) {
|
|
101
|
+
try {
|
|
102
|
+
text = JSON.stringify(JSON.parse(raw), null, 2)
|
|
103
|
+
} catch {
|
|
104
|
+
// Malformed or truncated JSON: show what is on disk instead of failing.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const allLines = text.split(/\r?\n/)
|
|
109
|
+
const match = String(args.match ?? '').trim()
|
|
110
|
+
const numbered = allLines.map((line, index) => ({ line, number: index + 1 }))
|
|
111
|
+
const filtered = match
|
|
112
|
+
? numbered.filter((item) => item.line.toLowerCase().includes(match.toLowerCase()))
|
|
113
|
+
: numbered
|
|
114
|
+
|
|
115
|
+
const offset = Number.isFinite(args.offset) && args.offset > 0 ? Math.floor(args.offset) : 0
|
|
116
|
+
const limit = Math.min(Math.max(Number.isFinite(args.limit) ? Math.floor(args.limit) : VIEW_DEFAULT_LIMIT, 1), VIEW_MAX_LIMIT)
|
|
117
|
+
const wrap = args.wrap === 0 ? 0 : Math.min(Math.max(Number.isFinite(args.wrap) ? Math.floor(args.wrap) : VIEW_WRAP_DEFAULT, 40), 4000)
|
|
118
|
+
const window = filtered.slice(offset, offset + limit)
|
|
119
|
+
|
|
120
|
+
const header = [
|
|
121
|
+
`文件:${file}`,
|
|
122
|
+
`大小:${formatBytes(size)} · 行数:${allLines.length}${pretty && isJson ? '(JSON 已展开)' : ''}${match ? ` · 匹配 "${match}":${filtered.length} 行` : ''}`,
|
|
123
|
+
]
|
|
124
|
+
const shownTo = offset + window.length
|
|
125
|
+
const remaining = filtered.length - shownTo
|
|
126
|
+
header.push(`第 ${offset + 1}–${shownTo} 行${remaining > 0 ? `(还有 ${remaining} 行,调 offset: ${shownTo} 继续)` : ''}`)
|
|
127
|
+
|
|
128
|
+
const body = []
|
|
129
|
+
for (const item of window) {
|
|
130
|
+
const pieces = wrap > 0 ? item.line.match(new RegExp(`[\\s\\S]{1,${wrap}}`, 'g')) ?? [''] : [item.line]
|
|
131
|
+
body.push(`${String(item.number).padStart(6)}│ ${pieces[0]}`)
|
|
132
|
+
for (const piece of pieces.slice(1)) body.push(`${' '.repeat(6)}│ ${piece}`)
|
|
133
|
+
}
|
|
134
|
+
if (!window.length) body.push(match ? '(没有匹配的行)' : '(文件为空)')
|
|
135
|
+
|
|
136
|
+
return { text: [...header, '', ...body].join('\n'), file }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function storageLines(summary) {
|
|
140
|
+
const lines = [`占用合计:${formatBytes(summary.totalBytes)}${summary.overLimit ? `(超过 ${summary.warnMegabytes} MB,建议清理)` : ''}`, '']
|
|
141
|
+
for (const row of summary.classes) {
|
|
142
|
+
lines.push(`- ${row.label}(${row.id}):${row.count} 个 · ${formatBytes(row.bytes)} · 保留 ${row.keep} 个 / ${row.maxAgeDays} 天${row.hint ? ` · ${row.hint}` : ''}`)
|
|
143
|
+
}
|
|
144
|
+
return lines
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function planLines(plan) {
|
|
148
|
+
const lines = [`将删除 ${plan.totalFiles} 个文件,释放 ${formatBytes(plan.totalBytes)}`, '']
|
|
149
|
+
for (const row of plan.classes) {
|
|
150
|
+
if (!row.remove.length) {
|
|
151
|
+
lines.push(`- ${row.label}:无需清理(${row.count} 个,保留 ${row.keep} 个 / ${row.maxAgeDays} 天)`)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
lines.push(`- ${row.label}:删除 ${row.remove.length} 个 · ${formatBytes(row.bytes)}`)
|
|
155
|
+
for (const item of row.remove.slice(0, 5)) {
|
|
156
|
+
lines.push(` · ${item.name}(${item.ageDays} 天前,${formatBytes(item.bytes)})`)
|
|
157
|
+
}
|
|
158
|
+
if (row.remove.length > 5) lines.push(` · … 其余 ${row.remove.length - 5} 个`)
|
|
159
|
+
}
|
|
160
|
+
return lines
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function register(ctx, state) {
|
|
164
|
+
const dispose = ctx.tools.register({
|
|
165
|
+
name: 'd365_reports',
|
|
166
|
+
description:
|
|
167
|
+
'查看本机数据占用与产物(.d365/reports、query 等),并按保留策略清理或压缩历史产物。cleanup 默认只预演(dryRun),确认后传 dryRun=false 才真正删除。',
|
|
168
|
+
parameters: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
additionalProperties: false,
|
|
171
|
+
properties: {
|
|
172
|
+
action: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
enum: ['list', 'cleanup', 'compact', 'view'],
|
|
175
|
+
description: 'list:看占用与产物;view:读回一个产物文件(分页/过滤);cleanup:按策略清理;compact:把已有快照压成紧凑 JSON。默认 list。',
|
|
176
|
+
},
|
|
177
|
+
file: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
description: 'view 的文件:.d365 下的相对路径(如 query/2026-11-06T01-36-47-274Z-layers.json)或文件名。',
|
|
180
|
+
},
|
|
181
|
+
offset: { type: 'number', description: 'view 从第几行开始(0 基),默认 0。' },
|
|
182
|
+
match: { type: 'string', description: 'view 只显示包含这个字符串的行(不区分大小写)。在大 JSON 里找一条 GUID 用这个。' },
|
|
183
|
+
wrap: { type: 'number', description: 'view 每行最多多少字符,超长折行;0 表示不折行。默认 400。' },
|
|
184
|
+
pretty: { type: 'boolean', description: 'view 是否把 JSON 缩进展开,默认对 .json 为 true。' },
|
|
185
|
+
target: {
|
|
186
|
+
type: 'string',
|
|
187
|
+
enum: CLASS_IDS,
|
|
188
|
+
description: 'cleanup 的范围:某一类或 all,默认 all。',
|
|
189
|
+
},
|
|
190
|
+
dryRun: {
|
|
191
|
+
type: 'boolean',
|
|
192
|
+
description: 'cleanup 默认 true,只预演不删除;确认要删时传 false。',
|
|
193
|
+
},
|
|
194
|
+
limit: { type: 'number', description: 'list 每类最多列多少条,默认 15。' },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
output: {
|
|
198
|
+
schema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
required: ['schemaVersion', 'action', 'text'],
|
|
202
|
+
properties: {
|
|
203
|
+
schemaVersion: { type: 'number' },
|
|
204
|
+
action: { type: 'string' },
|
|
205
|
+
text: { type: 'string' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
render: (args, value) => [{ type: 'text', text: value.text }],
|
|
209
|
+
},
|
|
210
|
+
async execute(args) {
|
|
211
|
+
const config = await loadConfig(state.rawConfig)
|
|
212
|
+
const action = args.action ?? 'list'
|
|
213
|
+
|
|
214
|
+
if (action === 'view') {
|
|
215
|
+
const view = await renderView(config, args)
|
|
216
|
+
return { schemaVersion: 1, action, text: view.text }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (action === 'compact') {
|
|
220
|
+
const result = await compactAllSnapshots(config)
|
|
221
|
+
const summary = await storageSummary(config)
|
|
222
|
+
const lines = [
|
|
223
|
+
`已压缩 ${result.snapshots} 份快照里的 ${result.files} 个文件,释放 ${formatBytes(result.savedBytes)}。`,
|
|
224
|
+
'',
|
|
225
|
+
...storageLines(summary),
|
|
226
|
+
]
|
|
227
|
+
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (action === 'cleanup') {
|
|
231
|
+
const target = CLASS_IDS.includes(args.target) ? args.target : 'all'
|
|
232
|
+
const dryRun = args.dryRun !== false
|
|
233
|
+
const plan = await planCleanup(config, { target })
|
|
234
|
+
if (dryRun) {
|
|
235
|
+
const lines = [`清理预演(${target}):尚未删除任何文件。`, '', ...planLines(plan)]
|
|
236
|
+
lines.push('', '确认要删除时,把 dryRun 设为 false 再调用一次。')
|
|
237
|
+
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
238
|
+
}
|
|
239
|
+
const applied = await applyCleanup(config, plan)
|
|
240
|
+
const summary = await storageSummary(config)
|
|
241
|
+
const lines = [
|
|
242
|
+
`已删除 ${applied.deleted.length} 个文件,释放 ${formatBytes(applied.bytes)}。`,
|
|
243
|
+
...(applied.failures.length ? ['', `失败 ${applied.failures.length} 个:`, ...applied.failures.map((item) => `- ${item.name}:${item.reason}`)] : []),
|
|
244
|
+
'',
|
|
245
|
+
...storageLines(summary),
|
|
246
|
+
]
|
|
247
|
+
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const limit = Number.isFinite(args.limit) ? Math.min(Math.max(args.limit, 1), 100) : 15
|
|
251
|
+
const root = dataRoot(config)
|
|
252
|
+
const reports = await listFiles(join(root, 'reports'), limit)
|
|
253
|
+
const queries = await listFiles(join(root, 'query'), limit)
|
|
254
|
+
const summary = await storageSummary(config)
|
|
255
|
+
|
|
256
|
+
let waiverCount = 0
|
|
257
|
+
try {
|
|
258
|
+
const document = await readWaivers(config)
|
|
259
|
+
waiverCount = (document.waivers ?? []).length
|
|
260
|
+
} catch {
|
|
261
|
+
waiverCount = 0
|
|
262
|
+
}
|
|
263
|
+
let lastContractRun = ''
|
|
264
|
+
try {
|
|
265
|
+
const history = await readHistory(config)
|
|
266
|
+
const last = history.runs?.[history.runs.length - 1]
|
|
267
|
+
if (last) {
|
|
268
|
+
lastContractRun = `${last.at}(${last.source} → ${(last.targets ?? []).join(', ')},阻断 ${last.blocker ?? 0} / 警告 ${last.warning ?? 0},豁免 ${last.waived ?? 0})`
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
lastContractRun = ''
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lines = ['本机数据', '', ...storageLines(summary), '']
|
|
275
|
+
// Totals come from the storage summary, not from the truncated listing:
|
|
276
|
+
// "25 个" next to three bullets looked like a bug.
|
|
277
|
+
const classCount = (id) => summary.classes.find((item) => item.id === id)?.count ?? 0
|
|
278
|
+
const reportTotal = classCount('reports')
|
|
279
|
+
lines.push(`报告(.d365/reports):${reportTotal} 个${reportTotal > reports.length ? `,下面是最近 ${reports.length} 个` : ''}`)
|
|
280
|
+
for (const row of reports) {
|
|
281
|
+
lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB · ${row.modifiedAt}`)
|
|
282
|
+
}
|
|
283
|
+
lines.push('')
|
|
284
|
+
const queryTotal = classCount('query')
|
|
285
|
+
const shownQueries = queries.slice(0, 8)
|
|
286
|
+
lines.push(`查询留证(.d365/query):${queryTotal} 个${queryTotal > shownQueries.length ? `,下面是最近 ${shownQueries.length} 个` : ''}`)
|
|
287
|
+
for (const row of shownQueries) {
|
|
288
|
+
lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB`)
|
|
289
|
+
}
|
|
290
|
+
lines.push('')
|
|
291
|
+
lines.push(`已登记豁免:${waiverCount} 条`)
|
|
292
|
+
lines.push(`最近一次契约检查:${lastContractRun || '还没有跑过'}`)
|
|
293
|
+
lines.push('')
|
|
294
|
+
lines.push('要读回某个产物(而不是只看文件名),用 action=view 加 file;大文件配 match 过滤、offset/limit 翻页。')
|
|
295
|
+
return { schemaVersion: 1, action: 'list', text: lines.join('\n') }
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
return dispose
|
|
299
|
+
}
|