dsh-d365-kit 0.34.1 → 0.34.4

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/retention.js CHANGED
@@ -1,384 +1,384 @@
1
- /**
2
- * Retention and storage hygiene for everything under `.d365/`.
3
- *
4
- * The rules are deliberately boring: keep the newest N of each class, plus a
5
- * maximum age as a second net. Deletions are always planned first, then applied
6
- * through a path guard that refuses anything outside the class directory, and
7
- * a few files are never touched at all (waivers, plans, config, secrets).
8
- */
9
-
10
- import { readdir, rm, stat, readFile, writeFile } from 'node:fs/promises'
11
- import { join, resolve, sep } from 'node:path'
12
- import { dataRoot, listSnapshots, snapshotsRoot } from './snapshot.js'
13
- import { DEFAULT_RETENTION } from './retention-defaults.js'
14
-
15
- export const DAY_MS = 24 * 60 * 60 * 1000
16
-
17
- /** Files that must survive every cleanup, by class. */
18
- const PROTECTED = {
19
- reports: new Set(['contract-history.json']),
20
- }
21
-
22
- export { DEFAULT_RETENTION }
23
-
24
- export const CLASSES = [
25
- {
26
- id: 'snapshots',
27
- label: '元数据快照',
28
- keepKey: 'snapshotKeep',
29
- daysKey: 'snapshotMaxAgeDays',
30
- minKeep: 1,
31
- perEnvironment: true,
32
- hint: '每个环境至少保留一份;最新一份永远不会被删。',
33
- },
34
- {
35
- id: 'reports',
36
- label: '分析报告',
37
- keepKey: 'reportKeep',
38
- daysKey: 'reportMaxAgeDays',
39
- minKeep: 1,
40
- // One report is a `.json` + `.md` pair; count the pair, not the files.
41
- paired: true,
42
- hint: '契约漂移、代码检查、迁移评估的报告;趋势文件永不删除。',
43
- },
44
- {
45
- id: 'diffs',
46
- label: '快照差异报告',
47
- keepKey: 'diffKeep',
48
- daysKey: 'diffMaxAgeDays',
49
- minKeep: 0,
50
- },
51
- {
52
- id: 'query',
53
- label: '查询留证',
54
- keepKey: 'queryKeep',
55
- daysKey: 'queryMaxAgeDays',
56
- minKeep: 0,
57
- hint: '各工具“本次查询结果”的留证;通常最大也最不值钱。',
58
- },
59
- {
60
- id: 'changes',
61
- label: '写入审计',
62
- keepKey: 'changeKeep',
63
- daysKey: 'changeMaxAgeDays',
64
- minKeep: 1,
65
- hint: '每次执行写入的 ledger,建议比其它类留得久。',
66
- },
67
- ]
68
-
69
- function numberOr(value, fallback, min) {
70
- return typeof value === 'number' && Number.isFinite(value) && value >= min ? value : fallback
71
- }
72
-
73
- /** Retention policy for one config, with defaults filled in. */
74
- export function retentionPolicy(config) {
75
- const source = config ?? {}
76
- const policy = {}
77
- for (const [key, fallback] of Object.entries(DEFAULT_RETENTION)) {
78
- const min = key === 'warnMegabytes' ? 50 : key.endsWith('Keep') ? 0 : 1
79
- policy[key] = numberOr(source[key], fallback, min)
80
- }
81
- return policy
82
- }
83
-
84
- function classDirectory(config, id) {
85
- return join(dataRoot(config), id)
86
- }
87
-
88
- function within(directory, target) {
89
- const base = resolve(directory)
90
- const full = resolve(target)
91
- return full === base || full.startsWith(base + sep)
92
- }
93
-
94
- async function sizeOf(path) {
95
- try {
96
- const info = await stat(path)
97
- return info.isFile() ? info.size : 0
98
- } catch {
99
- return 0
100
- }
101
- }
102
-
103
- /**
104
- * List one directory's entries, newest first.
105
- *
106
- * With `paired: true` a `.json` + `.md` pair written for the same report counts
107
- * as one entry. Counting files instead halves the effective `keep`: with
108
- * `reportKeep: 20` only ten reports survive, and the pair that a tool just
109
- * returned can lose its sibling.
110
- */
111
- async function listEntries(directory, options = {}) {
112
- let entries
113
- try {
114
- entries = await readdir(directory, { withFileTypes: true })
115
- } catch {
116
- return []
117
- }
118
- const rows = []
119
- for (const entry of entries) {
120
- const path = join(directory, entry.name)
121
- if (!entry.isFile() && !entry.isDirectory()) continue
122
- let info
123
- try {
124
- info = await stat(path)
125
- } catch {
126
- continue
127
- }
128
- let bytes = info.size
129
- if (entry.isDirectory()) {
130
- bytes = 0
131
- try {
132
- for (const child of await readdir(path, { withFileTypes: true })) {
133
- if (child.isFile()) bytes += await sizeOf(join(path, child.name))
134
- }
135
- } catch {
136
- bytes = 0
137
- }
138
- }
139
- rows.push({ name: entry.name, path, files: [path], bytes, modifiedAt: info.mtime })
140
- }
141
- if (!options.paired) return rows.sort((a, b) => b.modifiedAt - a.modifiedAt)
142
-
143
- const byBase = new Map()
144
- for (const row of rows) {
145
- const base = row.name.replace(/\.(json|md|txt|log)$/i, '')
146
- const existing = byBase.get(base)
147
- if (!existing) {
148
- byBase.set(base, { ...row, name: base })
149
- continue
150
- }
151
- existing.files.push(row.path)
152
- existing.bytes += row.bytes
153
- if (row.modifiedAt > existing.modifiedAt) {
154
- existing.modifiedAt = row.modifiedAt
155
- existing.path = row.path
156
- }
157
- }
158
- return [...byBase.values()].sort((a, b) => b.modifiedAt - a.modifiedAt)
159
- }
160
-
161
- async function snapshotEntries(config) {
162
- const root = snapshotsRoot(config)
163
- let environments = []
164
- try {
165
- environments = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory())
166
- } catch {
167
- return []
168
- }
169
- const rows = []
170
- for (const env of environments) {
171
- const snapshots = await listSnapshots(config, env.name)
172
- for (const snapshot of snapshots) {
173
- let bytes = 0
174
- try {
175
- for (const child of await readdir(snapshot.dir, { withFileTypes: true })) {
176
- if (child.isFile()) bytes += await sizeOf(join(snapshot.dir, child.name))
177
- }
178
- } catch {
179
- bytes = 0
180
- }
181
- rows.push({
182
- name: `${env.name}/${snapshot.id}`,
183
- group: env.name,
184
- path: snapshot.dir,
185
- bytes,
186
- modifiedAt: new Date(snapshot.manifest?.finishedAt ?? snapshot.manifest?.startedAt ?? Date.now()),
187
- })
188
- }
189
- }
190
- return rows.sort((a, b) => b.modifiedAt - a.modifiedAt)
191
- }
192
-
193
- /** Summary of what is on disk, per class. */
194
- export async function storageSummary(config) {
195
- const policy = retentionPolicy(config)
196
- const classes = []
197
- let totalBytes = 0
198
-
199
- for (const definition of CLASSES) {
200
- const rows = definition.id === 'snapshots'
201
- ? await snapshotEntries(config)
202
- : await listEntries(classDirectory(config, definition.id), { paired: definition.paired === true })
203
-
204
- const bytes = rows.reduce((sum, row) => sum + row.bytes, 0)
205
- totalBytes += bytes
206
- const protectedNames = PROTECTED[definition.id] ?? new Set()
207
- const eligible = rows.filter((row) => !protectedNames.has(row.name))
208
-
209
- classes.push({
210
- id: definition.id,
211
- label: definition.label,
212
- hint: definition.hint ?? '',
213
- count: rows.length,
214
- bytes,
215
- oldestAt: eligible.length ? eligible[eligible.length - 1].modifiedAt.toISOString() : '',
216
- newestAt: eligible.length ? eligible[0].modifiedAt.toISOString() : '',
217
- keep: policy[definition.keepKey],
218
- maxAgeDays: policy[definition.daysKey],
219
- perEnvironment: definition.perEnvironment === true,
220
- })
221
- }
222
-
223
- return {
224
- totalBytes,
225
- warnMegabytes: policy.warnMegabytes,
226
- overLimit: totalBytes > policy.warnMegabytes * 1024 * 1024,
227
- classes,
228
- policy,
229
- }
230
- }
231
-
232
- /**
233
- * Dry-run what a cleanup would remove.
234
- * @param options - `{ target }` where target is a class id or 'all'.
235
- */
236
- export async function planCleanup(config, options = {}) {
237
- const policy = retentionPolicy(config)
238
- const target = options.target ?? 'all'
239
- const now = options.now ? new Date(options.now) : new Date()
240
- const classes = []
241
- let totalBytes = 0
242
-
243
- for (const definition of CLASSES) {
244
- if (target !== 'all' && target !== definition.id) continue
245
- const keep = policy[definition.keepKey]
246
- const maxAgeDays = policy[definition.daysKey]
247
- const rows = definition.id === 'snapshots'
248
- ? await snapshotEntries(config)
249
- : await listEntries(classDirectory(config, definition.id), { paired: definition.paired === true })
250
- const protectedNames = PROTECTED[definition.id] ?? new Set()
251
-
252
- const remove = []
253
- const groups = definition.perEnvironment
254
- ? [...new Set(rows.map((row) => row.group))].map((group) => rows.filter((row) => row.group === group))
255
- : [rows]
256
-
257
- for (const group of groups) {
258
- // `rows` is newest-first; per environment the first `keep` survive.
259
- group.forEach((row, index) => {
260
- if (protectedNames.has(row.name)) return
261
- const ageDays = (now.getTime() - row.modifiedAt.getTime()) / DAY_MS
262
- const overCount = index >= keep
263
- const overAge = ageDays > maxAgeDays && index >= definition.minKeep
264
- if (overCount || overAge) {
265
- remove.push({ name: row.name, path: row.path, bytes: row.bytes, ageDays: Math.round(ageDays) })
266
- }
267
- })
268
- }
269
-
270
- const bytes = remove.reduce((sum, row) => sum + row.bytes, 0)
271
- totalBytes += bytes
272
- classes.push({
273
- id: definition.id,
274
- label: definition.label,
275
- keep,
276
- maxAgeDays,
277
- count: rows.length,
278
- remove: remove.sort((a, b) => b.ageDays - a.ageDays),
279
- bytes,
280
- })
281
- }
282
-
283
- return { target, totalBytes, totalFiles: classes.reduce((sum, row) => sum + row.remove.length, 0), classes }
284
- }
285
-
286
- /**
287
- * Delete what a plan says to delete. Every path is re-checked against its class
288
- * directory, so a malformed plan entry can never escape `.d365/`.
289
- */
290
- export async function applyCleanup(config, plan) {
291
- const deleted = []
292
- const failures = []
293
- let bytes = 0
294
-
295
- for (const item of plan.classes ?? []) {
296
- const directory = item.id === 'snapshots'
297
- ? snapshotsRoot(config)
298
- : classDirectory(config, item.id)
299
- for (const entry of item.remove ?? []) {
300
- // A paired report is one entry holding several files.
301
- const paths = entry.files ?? [entry.path]
302
- const outside = paths.filter((path) => !within(directory, path))
303
- if (outside.length) {
304
- failures.push({ name: entry.name, reason: '路径不在允许的目录内' })
305
- continue
306
- }
307
- try {
308
- for (const path of paths) await rm(path, { recursive: true, force: true })
309
- deleted.push({ class: item.id, name: entry.name })
310
- bytes += entry.bytes ?? 0
311
- } catch (error) {
312
- failures.push({ name: entry.name, reason: error?.message ?? String(error) })
313
- }
314
- }
315
- }
316
-
317
- return { deleted, failures, bytes }
318
- }
319
-
320
- /**
321
- * Apply the policy silently. Called after a tool writes its artifact, so the
322
- * directory never grows without bound. Failures are swallowed: retention must
323
- * never break a real operation.
324
- */
325
- export async function enforceRetention(config, options = {}) {
326
- try {
327
- const plan = await planCleanup(config, { target: options.target ?? 'all' })
328
- if (!plan.totalFiles) return { deleted: 0, bytes: 0 }
329
- const applied = await applyCleanup(config, plan)
330
- return { deleted: applied.deleted.length, bytes: applied.bytes }
331
- } catch {
332
- return { deleted: 0, bytes: 0 }
333
- }
334
- }
335
-
336
- /** Compact one snapshot's resource files (pretty JSON → single line). */export async function compactSnapshot(config, envKey, id) {
337
- const snapshots = await listSnapshots(config, envKey)
338
- const snapshot = snapshots.find((item) => item.id === id)
339
- if (!snapshot) return { files: 0, savedBytes: 0 }
340
- let files = 0
341
- let savedBytes = 0
342
- for (const name of await readdir(snapshot.dir)) {
343
- if (!name.endsWith('.json') || name === 'manifest.json') continue
344
- const path = join(snapshot.dir, name)
345
- try {
346
- const text = await readFile(path, 'utf8')
347
- const payload = JSON.parse(text)
348
- const compact = `${JSON.stringify(payload)}\n`
349
- if (compact.length < text.length) {
350
- savedBytes += text.length - compact.length
351
- await writeFile(path, compact, 'utf8')
352
- files += 1
353
- }
354
- } catch {
355
- // A snapshot file that no longer parses is left alone.
356
- }
357
- }
358
- return { files, savedBytes }
359
- }
360
-
361
- /** Compact every snapshot on disk. */
362
- export async function compactAllSnapshots(config) {
363
- const policyReport = { snapshots: 0, files: 0, savedBytes: 0 }
364
- for (const row of await snapshotEntries(config)) {
365
- const [envKey, id] = row.name.split('/')
366
- const result = await compactSnapshot(config, envKey, id)
367
- policyReport.snapshots += 1
368
- policyReport.files += result.files
369
- policyReport.savedBytes += result.savedBytes
370
- }
371
- return policyReport
372
- }
373
-
374
- export function formatBytes(bytes) {
375
- if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
376
- const units = ['B', 'KB', 'MB', 'GB']
377
- let value = bytes
378
- let unit = 0
379
- while (value >= 1024 && unit < units.length - 1) {
380
- value /= 1024
381
- unit += 1
382
- }
383
- return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
384
- }
1
+ /**
2
+ * Retention and storage hygiene for everything under `.d365/`.
3
+ *
4
+ * The rules are deliberately boring: keep the newest N of each class, plus a
5
+ * maximum age as a second net. Deletions are always planned first, then applied
6
+ * through a path guard that refuses anything outside the class directory, and
7
+ * a few files are never touched at all (waivers, plans, config, secrets).
8
+ */
9
+
10
+ import { readdir, rm, stat, readFile, writeFile } from 'node:fs/promises'
11
+ import { join, resolve, sep } from 'node:path'
12
+ import { dataRoot, listSnapshots, snapshotsRoot } from './snapshot.js'
13
+ import { DEFAULT_RETENTION } from './retention-defaults.js'
14
+
15
+ export const DAY_MS = 24 * 60 * 60 * 1000
16
+
17
+ /** Files that must survive every cleanup, by class. */
18
+ const PROTECTED = {
19
+ reports: new Set(['contract-history.json']),
20
+ }
21
+
22
+ export { DEFAULT_RETENTION }
23
+
24
+ export const CLASSES = [
25
+ {
26
+ id: 'snapshots',
27
+ label: '元数据快照',
28
+ keepKey: 'snapshotKeep',
29
+ daysKey: 'snapshotMaxAgeDays',
30
+ minKeep: 1,
31
+ perEnvironment: true,
32
+ hint: '每个环境至少保留一份;最新一份永远不会被删。',
33
+ },
34
+ {
35
+ id: 'reports',
36
+ label: '分析报告',
37
+ keepKey: 'reportKeep',
38
+ daysKey: 'reportMaxAgeDays',
39
+ minKeep: 1,
40
+ // One report is a `.json` + `.md` pair; count the pair, not the files.
41
+ paired: true,
42
+ hint: '契约漂移、代码检查、迁移评估的报告;趋势文件永不删除。',
43
+ },
44
+ {
45
+ id: 'diffs',
46
+ label: '快照差异报告',
47
+ keepKey: 'diffKeep',
48
+ daysKey: 'diffMaxAgeDays',
49
+ minKeep: 0,
50
+ },
51
+ {
52
+ id: 'query',
53
+ label: '查询留证',
54
+ keepKey: 'queryKeep',
55
+ daysKey: 'queryMaxAgeDays',
56
+ minKeep: 0,
57
+ hint: '各工具“本次查询结果”的留证;通常最大也最不值钱。',
58
+ },
59
+ {
60
+ id: 'changes',
61
+ label: '写入审计',
62
+ keepKey: 'changeKeep',
63
+ daysKey: 'changeMaxAgeDays',
64
+ minKeep: 1,
65
+ hint: '每次执行写入的 ledger,建议比其它类留得久。',
66
+ },
67
+ ]
68
+
69
+ function numberOr(value, fallback, min) {
70
+ return typeof value === 'number' && Number.isFinite(value) && value >= min ? value : fallback
71
+ }
72
+
73
+ /** Retention policy for one config, with defaults filled in. */
74
+ export function retentionPolicy(config) {
75
+ const source = config ?? {}
76
+ const policy = {}
77
+ for (const [key, fallback] of Object.entries(DEFAULT_RETENTION)) {
78
+ const min = key === 'warnMegabytes' ? 50 : key.endsWith('Keep') ? 0 : 1
79
+ policy[key] = numberOr(source[key], fallback, min)
80
+ }
81
+ return policy
82
+ }
83
+
84
+ function classDirectory(config, id) {
85
+ return join(dataRoot(config), id)
86
+ }
87
+
88
+ function within(directory, target) {
89
+ const base = resolve(directory)
90
+ const full = resolve(target)
91
+ return full === base || full.startsWith(base + sep)
92
+ }
93
+
94
+ async function sizeOf(path) {
95
+ try {
96
+ const info = await stat(path)
97
+ return info.isFile() ? info.size : 0
98
+ } catch {
99
+ return 0
100
+ }
101
+ }
102
+
103
+ /**
104
+ * List one directory's entries, newest first.
105
+ *
106
+ * With `paired: true` a `.json` + `.md` pair written for the same report counts
107
+ * as one entry. Counting files instead halves the effective `keep`: with
108
+ * `reportKeep: 20` only ten reports survive, and the pair that a tool just
109
+ * returned can lose its sibling.
110
+ */
111
+ async function listEntries(directory, options = {}) {
112
+ let entries
113
+ try {
114
+ entries = await readdir(directory, { withFileTypes: true })
115
+ } catch {
116
+ return []
117
+ }
118
+ const rows = []
119
+ for (const entry of entries) {
120
+ const path = join(directory, entry.name)
121
+ if (!entry.isFile() && !entry.isDirectory()) continue
122
+ let info
123
+ try {
124
+ info = await stat(path)
125
+ } catch {
126
+ continue
127
+ }
128
+ let bytes = info.size
129
+ if (entry.isDirectory()) {
130
+ bytes = 0
131
+ try {
132
+ for (const child of await readdir(path, { withFileTypes: true })) {
133
+ if (child.isFile()) bytes += await sizeOf(join(path, child.name))
134
+ }
135
+ } catch {
136
+ bytes = 0
137
+ }
138
+ }
139
+ rows.push({ name: entry.name, path, files: [path], bytes, modifiedAt: info.mtime })
140
+ }
141
+ if (!options.paired) return rows.sort((a, b) => b.modifiedAt - a.modifiedAt)
142
+
143
+ const byBase = new Map()
144
+ for (const row of rows) {
145
+ const base = row.name.replace(/\.(json|md|txt|log)$/i, '')
146
+ const existing = byBase.get(base)
147
+ if (!existing) {
148
+ byBase.set(base, { ...row, name: base })
149
+ continue
150
+ }
151
+ existing.files.push(row.path)
152
+ existing.bytes += row.bytes
153
+ if (row.modifiedAt > existing.modifiedAt) {
154
+ existing.modifiedAt = row.modifiedAt
155
+ existing.path = row.path
156
+ }
157
+ }
158
+ return [...byBase.values()].sort((a, b) => b.modifiedAt - a.modifiedAt)
159
+ }
160
+
161
+ async function snapshotEntries(config) {
162
+ const root = snapshotsRoot(config)
163
+ let environments = []
164
+ try {
165
+ environments = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory())
166
+ } catch {
167
+ return []
168
+ }
169
+ const rows = []
170
+ for (const env of environments) {
171
+ const snapshots = await listSnapshots(config, env.name)
172
+ for (const snapshot of snapshots) {
173
+ let bytes = 0
174
+ try {
175
+ for (const child of await readdir(snapshot.dir, { withFileTypes: true })) {
176
+ if (child.isFile()) bytes += await sizeOf(join(snapshot.dir, child.name))
177
+ }
178
+ } catch {
179
+ bytes = 0
180
+ }
181
+ rows.push({
182
+ name: `${env.name}/${snapshot.id}`,
183
+ group: env.name,
184
+ path: snapshot.dir,
185
+ bytes,
186
+ modifiedAt: new Date(snapshot.manifest?.finishedAt ?? snapshot.manifest?.startedAt ?? Date.now()),
187
+ })
188
+ }
189
+ }
190
+ return rows.sort((a, b) => b.modifiedAt - a.modifiedAt)
191
+ }
192
+
193
+ /** Summary of what is on disk, per class. */
194
+ export async function storageSummary(config) {
195
+ const policy = retentionPolicy(config)
196
+ const classes = []
197
+ let totalBytes = 0
198
+
199
+ for (const definition of CLASSES) {
200
+ const rows = definition.id === 'snapshots'
201
+ ? await snapshotEntries(config)
202
+ : await listEntries(classDirectory(config, definition.id), { paired: definition.paired === true })
203
+
204
+ const bytes = rows.reduce((sum, row) => sum + row.bytes, 0)
205
+ totalBytes += bytes
206
+ const protectedNames = PROTECTED[definition.id] ?? new Set()
207
+ const eligible = rows.filter((row) => !protectedNames.has(row.name))
208
+
209
+ classes.push({
210
+ id: definition.id,
211
+ label: definition.label,
212
+ hint: definition.hint ?? '',
213
+ count: rows.length,
214
+ bytes,
215
+ oldestAt: eligible.length ? eligible[eligible.length - 1].modifiedAt.toISOString() : '',
216
+ newestAt: eligible.length ? eligible[0].modifiedAt.toISOString() : '',
217
+ keep: policy[definition.keepKey],
218
+ maxAgeDays: policy[definition.daysKey],
219
+ perEnvironment: definition.perEnvironment === true,
220
+ })
221
+ }
222
+
223
+ return {
224
+ totalBytes,
225
+ warnMegabytes: policy.warnMegabytes,
226
+ overLimit: totalBytes > policy.warnMegabytes * 1024 * 1024,
227
+ classes,
228
+ policy,
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Dry-run what a cleanup would remove.
234
+ * @param options - `{ target }` where target is a class id or 'all'.
235
+ */
236
+ export async function planCleanup(config, options = {}) {
237
+ const policy = retentionPolicy(config)
238
+ const target = options.target ?? 'all'
239
+ const now = options.now ? new Date(options.now) : new Date()
240
+ const classes = []
241
+ let totalBytes = 0
242
+
243
+ for (const definition of CLASSES) {
244
+ if (target !== 'all' && target !== definition.id) continue
245
+ const keep = policy[definition.keepKey]
246
+ const maxAgeDays = policy[definition.daysKey]
247
+ const rows = definition.id === 'snapshots'
248
+ ? await snapshotEntries(config)
249
+ : await listEntries(classDirectory(config, definition.id), { paired: definition.paired === true })
250
+ const protectedNames = PROTECTED[definition.id] ?? new Set()
251
+
252
+ const remove = []
253
+ const groups = definition.perEnvironment
254
+ ? [...new Set(rows.map((row) => row.group))].map((group) => rows.filter((row) => row.group === group))
255
+ : [rows]
256
+
257
+ for (const group of groups) {
258
+ // `rows` is newest-first; per environment the first `keep` survive.
259
+ group.forEach((row, index) => {
260
+ if (protectedNames.has(row.name)) return
261
+ const ageDays = (now.getTime() - row.modifiedAt.getTime()) / DAY_MS
262
+ const overCount = index >= keep
263
+ const overAge = ageDays > maxAgeDays && index >= definition.minKeep
264
+ if (overCount || overAge) {
265
+ remove.push({ name: row.name, path: row.path, bytes: row.bytes, ageDays: Math.round(ageDays) })
266
+ }
267
+ })
268
+ }
269
+
270
+ const bytes = remove.reduce((sum, row) => sum + row.bytes, 0)
271
+ totalBytes += bytes
272
+ classes.push({
273
+ id: definition.id,
274
+ label: definition.label,
275
+ keep,
276
+ maxAgeDays,
277
+ count: rows.length,
278
+ remove: remove.sort((a, b) => b.ageDays - a.ageDays),
279
+ bytes,
280
+ })
281
+ }
282
+
283
+ return { target, totalBytes, totalFiles: classes.reduce((sum, row) => sum + row.remove.length, 0), classes }
284
+ }
285
+
286
+ /**
287
+ * Delete what a plan says to delete. Every path is re-checked against its class
288
+ * directory, so a malformed plan entry can never escape `.d365/`.
289
+ */
290
+ export async function applyCleanup(config, plan) {
291
+ const deleted = []
292
+ const failures = []
293
+ let bytes = 0
294
+
295
+ for (const item of plan.classes ?? []) {
296
+ const directory = item.id === 'snapshots'
297
+ ? snapshotsRoot(config)
298
+ : classDirectory(config, item.id)
299
+ for (const entry of item.remove ?? []) {
300
+ // A paired report is one entry holding several files.
301
+ const paths = entry.files ?? [entry.path]
302
+ const outside = paths.filter((path) => !within(directory, path))
303
+ if (outside.length) {
304
+ failures.push({ name: entry.name, reason: '路径不在允许的目录内' })
305
+ continue
306
+ }
307
+ try {
308
+ for (const path of paths) await rm(path, { recursive: true, force: true })
309
+ deleted.push({ class: item.id, name: entry.name })
310
+ bytes += entry.bytes ?? 0
311
+ } catch (error) {
312
+ failures.push({ name: entry.name, reason: error?.message ?? String(error) })
313
+ }
314
+ }
315
+ }
316
+
317
+ return { deleted, failures, bytes }
318
+ }
319
+
320
+ /**
321
+ * Apply the policy silently. Called after a tool writes its artifact, so the
322
+ * directory never grows without bound. Failures are swallowed: retention must
323
+ * never break a real operation.
324
+ */
325
+ export async function enforceRetention(config, options = {}) {
326
+ try {
327
+ const plan = await planCleanup(config, { target: options.target ?? 'all' })
328
+ if (!plan.totalFiles) return { deleted: 0, bytes: 0 }
329
+ const applied = await applyCleanup(config, plan)
330
+ return { deleted: applied.deleted.length, bytes: applied.bytes }
331
+ } catch {
332
+ return { deleted: 0, bytes: 0 }
333
+ }
334
+ }
335
+
336
+ /** Compact one snapshot's resource files (pretty JSON → single line). */export async function compactSnapshot(config, envKey, id) {
337
+ const snapshots = await listSnapshots(config, envKey)
338
+ const snapshot = snapshots.find((item) => item.id === id)
339
+ if (!snapshot) return { files: 0, savedBytes: 0 }
340
+ let files = 0
341
+ let savedBytes = 0
342
+ for (const name of await readdir(snapshot.dir)) {
343
+ if (!name.endsWith('.json') || name === 'manifest.json') continue
344
+ const path = join(snapshot.dir, name)
345
+ try {
346
+ const text = await readFile(path, 'utf8')
347
+ const payload = JSON.parse(text)
348
+ const compact = `${JSON.stringify(payload)}\n`
349
+ if (compact.length < text.length) {
350
+ savedBytes += text.length - compact.length
351
+ await writeFile(path, compact, 'utf8')
352
+ files += 1
353
+ }
354
+ } catch {
355
+ // A snapshot file that no longer parses is left alone.
356
+ }
357
+ }
358
+ return { files, savedBytes }
359
+ }
360
+
361
+ /** Compact every snapshot on disk. */
362
+ export async function compactAllSnapshots(config) {
363
+ const policyReport = { snapshots: 0, files: 0, savedBytes: 0 }
364
+ for (const row of await snapshotEntries(config)) {
365
+ const [envKey, id] = row.name.split('/')
366
+ const result = await compactSnapshot(config, envKey, id)
367
+ policyReport.snapshots += 1
368
+ policyReport.files += result.files
369
+ policyReport.savedBytes += result.savedBytes
370
+ }
371
+ return policyReport
372
+ }
373
+
374
+ export function formatBytes(bytes) {
375
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
376
+ const units = ['B', 'KB', 'MB', 'GB']
377
+ let value = bytes
378
+ let unit = 0
379
+ while (value >= 1024 && unit < units.length - 1) {
380
+ value /= 1024
381
+ unit += 1
382
+ }
383
+ return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
384
+ }