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.
- package/CHANGELOG.md +319 -308
- package/CHANGELOG.zh-CN.md +318 -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/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
|
+
}
|