mandrel 2.42.0 → 2.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +4 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -1
- package/.agents/scripts/lib/ITicketingProvider.js +27 -0
- package/.agents/scripts/lib/config-settings-schema.js +29 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches-detect.js +187 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +37 -90
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +115 -19
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +10 -2
- package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
- package/.agents/scripts/lib/orchestration/plan-run-labels/reap.js +327 -0
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +117 -36
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +12 -7
- package/.agents/scripts/providers/github/labels.js +88 -0
- package/.agents/scripts/providers/github.js +2 -0
- package/.agents/scripts/prune-plan-run-labels.js +218 -0
- package/.agents/workflows/memory-consolidate.md +18 -6
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-run-labels/reap.js — the cohort label's end of life (Story #5189).
|
|
3
|
+
*
|
|
4
|
+
* `plan-persist` mints one `plan-run::<id>` label per run so the Stories one
|
|
5
|
+
* plan authored stay filterable in the GitHub UI. That is load-bearing while
|
|
6
|
+
* any Story in the cohort is open, and inert the moment they are all closed —
|
|
7
|
+
* but nothing expressed the second half, so the vocabulary grew one label per
|
|
8
|
+
* persist forever. A consumer repository measured 235 `plan-run::` labels out
|
|
9
|
+
* of 313 total; past that a paged label listing stops seeing the labels that
|
|
10
|
+
* sort after the pile, and every list-then-create caller starts failing.
|
|
11
|
+
*
|
|
12
|
+
* This module is the **one decision engine** behind both surfaces that act on
|
|
13
|
+
* that end of life: the per-Story close tail (automatic, incremental) and
|
|
14
|
+
* `prune-plan-run-labels.js` (manual, whole-repository). Keeping the decision
|
|
15
|
+
* here rather than in either caller is what stops the two from disagreeing
|
|
16
|
+
* about when a label is spent.
|
|
17
|
+
*
|
|
18
|
+
* **The decision.** A cohort label is reapable only when it carries at least
|
|
19
|
+
* one issue and every issue carrying it is closed. The "at least one" clause
|
|
20
|
+
* is the subtle half: a label carrying *zero* issues is indistinguishable from
|
|
21
|
+
* one an in-flight persist has just minted before creating its Stories, and
|
|
22
|
+
* deleting it would break that run. So a zero-issue label is reported under an
|
|
23
|
+
* `unreferenced` reason and reaped only under an explicit opt-in — a default
|
|
24
|
+
* sweep is safe to run concurrently with a persist.
|
|
25
|
+
*
|
|
26
|
+
* **Never load-bearing.** Both surfaces treat a reap failure as a warning.
|
|
27
|
+
* Label hygiene is a chore; nothing downstream reads the cohort label as an
|
|
28
|
+
* input (see `PLAN_RUN_LABEL_PREFIX`'s own docblock), so a failed delete costs
|
|
29
|
+
* a stale label and nothing else.
|
|
30
|
+
*
|
|
31
|
+
* @module lib/orchestration/plan-run-labels/reap
|
|
32
|
+
* @see Story #5189
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { PLAN_RUN_LABEL_PREFIX } from '../plan-persist/story-ops.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a cohort label was — or was not — judged reapable. One reason per
|
|
39
|
+
* label, so a `--json` report is auditable without re-deriving anything.
|
|
40
|
+
*
|
|
41
|
+
* - `all-closed` — carries issues, every one closed. Reapable.
|
|
42
|
+
* - `open-stories` — at least one issue still open. Not reapable, ever.
|
|
43
|
+
* - `unreferenced` — carries no issues at all. Reapable only under the
|
|
44
|
+
* explicit opt-in, because an in-flight persist looks exactly like this.
|
|
45
|
+
*/
|
|
46
|
+
export const REAP_REASONS = Object.freeze({
|
|
47
|
+
ALL_CLOSED: 'all-closed',
|
|
48
|
+
OPEN_STORIES: 'open-stories',
|
|
49
|
+
UNREFERENCED: 'unreferenced',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Is `name` a plan-run cohort label?
|
|
54
|
+
*
|
|
55
|
+
* The prefix is imported from `plan-persist/story-ops.js` rather than
|
|
56
|
+
* re-declared: minting and reaping must not be able to drift onto two
|
|
57
|
+
* different strings, which is exactly the failure a copied literal invites.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} name
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
function isPlanRunLabel(name) {
|
|
63
|
+
return typeof name === 'string' && name.startsWith(PLAN_RUN_LABEL_PREFIX);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Project an arbitrary label collection onto the sorted, de-duplicated set of
|
|
68
|
+
* cohort label names. Accepts either bare strings (a ticket's `labels[]`) or
|
|
69
|
+
* `{ name }` rows (the label listing port), so both callers hand this whatever
|
|
70
|
+
* their own read returned.
|
|
71
|
+
*
|
|
72
|
+
* @param {Array<string|{ name?: string }>} [names]
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
function selectCohortLabels(names) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const raw of Array.isArray(names) ? names : []) {
|
|
78
|
+
const name = typeof raw === 'string' ? raw : raw?.name;
|
|
79
|
+
if (isPlanRunLabel(name)) seen.add(name);
|
|
80
|
+
}
|
|
81
|
+
return [...seen].sort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Decide one cohort label.
|
|
86
|
+
*
|
|
87
|
+
* Reads through `listIssuesByLabel({ state: 'all' })` — the paginating read
|
|
88
|
+
* port — so the verdict is never a function of how many issues fit on one API
|
|
89
|
+
* page. `state` is compared case-insensitively against `closed` and anything
|
|
90
|
+
* else counts as open: an unknown state must never be read as "safe to
|
|
91
|
+
* delete".
|
|
92
|
+
*
|
|
93
|
+
* @param {{ provider: object, label: string, includeUnreferenced?: boolean }} args
|
|
94
|
+
* @returns {Promise<{
|
|
95
|
+
* label: string,
|
|
96
|
+
* reapable: boolean,
|
|
97
|
+
* reason: string,
|
|
98
|
+
* issueCount: number,
|
|
99
|
+
* openIssues: number[],
|
|
100
|
+
* }>}
|
|
101
|
+
*/
|
|
102
|
+
async function decideCohortLabel({
|
|
103
|
+
provider,
|
|
104
|
+
label,
|
|
105
|
+
includeUnreferenced = false,
|
|
106
|
+
}) {
|
|
107
|
+
const result = await provider.listIssuesByLabel({
|
|
108
|
+
state: 'all',
|
|
109
|
+
labels: label,
|
|
110
|
+
});
|
|
111
|
+
const issues = Array.isArray(result) ? result : [];
|
|
112
|
+
const open = issues.filter(
|
|
113
|
+
(issue) => String(issue?.state ?? '').toLowerCase() !== 'closed',
|
|
114
|
+
);
|
|
115
|
+
if (issues.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
label,
|
|
118
|
+
reapable: includeUnreferenced === true,
|
|
119
|
+
reason: REAP_REASONS.UNREFERENCED,
|
|
120
|
+
issueCount: 0,
|
|
121
|
+
openIssues: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (open.length > 0) {
|
|
125
|
+
return {
|
|
126
|
+
label,
|
|
127
|
+
reapable: false,
|
|
128
|
+
reason: REAP_REASONS.OPEN_STORIES,
|
|
129
|
+
issueCount: issues.length,
|
|
130
|
+
openIssues: open
|
|
131
|
+
.map((issue) => issue?.number)
|
|
132
|
+
.filter((n) => Number.isInteger(n)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
label,
|
|
137
|
+
reapable: true,
|
|
138
|
+
reason: REAP_REASONS.ALL_CLOSED,
|
|
139
|
+
issueCount: issues.length,
|
|
140
|
+
openIssues: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Decide a set of cohort labels.
|
|
146
|
+
*
|
|
147
|
+
* Sequential on purpose. The whole-repository sweep can face hundreds of
|
|
148
|
+
* labels, and a fan-out over a shared REST budget buys wall-clock at the cost
|
|
149
|
+
* of the one property an operator auditing a pile actually needs: a
|
|
150
|
+
* deterministic, label-ordered report.
|
|
151
|
+
*
|
|
152
|
+
* @param {{
|
|
153
|
+
* provider: object,
|
|
154
|
+
* labels: Array<string|{ name?: string }>,
|
|
155
|
+
* includeUnreferenced?: boolean,
|
|
156
|
+
* }} args
|
|
157
|
+
* @returns {Promise<Array<object>>} one decision per cohort label, name-sorted.
|
|
158
|
+
*/
|
|
159
|
+
async function evaluateCohortLabels({
|
|
160
|
+
provider,
|
|
161
|
+
labels,
|
|
162
|
+
includeUnreferenced = false,
|
|
163
|
+
}) {
|
|
164
|
+
const decisions = [];
|
|
165
|
+
for (const label of selectCohortLabels(labels)) {
|
|
166
|
+
decisions.push(
|
|
167
|
+
await decideCohortLabel({ provider, label, includeUnreferenced }),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return decisions;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decide, then (unless `check`) delete.
|
|
175
|
+
*
|
|
176
|
+
* A delete that throws is recorded in `failed[]` and warned about rather than
|
|
177
|
+
* propagated: one unreachable label must not abandon the rest of a sweep, and
|
|
178
|
+
* on the close path it must not touch the land. A delete the provider reports
|
|
179
|
+
* as a no-op (`deleted: false` — the label was already gone) is still a
|
|
180
|
+
* success; that is what makes a re-run idempotent.
|
|
181
|
+
*
|
|
182
|
+
* @param {{
|
|
183
|
+
* provider: object,
|
|
184
|
+
* labels: Array<string|{ name?: string }>,
|
|
185
|
+
* includeUnreferenced?: boolean,
|
|
186
|
+
* check?: boolean,
|
|
187
|
+
* onWarn?: ((message: string) => void)|null,
|
|
188
|
+
* }} args
|
|
189
|
+
* @returns {Promise<{
|
|
190
|
+
* check: boolean,
|
|
191
|
+
* decisions: Array<object>,
|
|
192
|
+
* reapable: string[],
|
|
193
|
+
* deleted: Array<{ label: string, existed: boolean }>,
|
|
194
|
+
* failed: Array<{ label: string, detail: string }>,
|
|
195
|
+
* }>}
|
|
196
|
+
*/
|
|
197
|
+
async function reapCohortLabels({
|
|
198
|
+
provider,
|
|
199
|
+
labels,
|
|
200
|
+
includeUnreferenced = false,
|
|
201
|
+
check = false,
|
|
202
|
+
onWarn = null,
|
|
203
|
+
}) {
|
|
204
|
+
const decisions = await evaluateCohortLabels({
|
|
205
|
+
provider,
|
|
206
|
+
labels,
|
|
207
|
+
includeUnreferenced,
|
|
208
|
+
});
|
|
209
|
+
const reapable = decisions.filter((d) => d.reapable).map((d) => d.label);
|
|
210
|
+
const deleted = [];
|
|
211
|
+
const failed = [];
|
|
212
|
+
if (check !== true) {
|
|
213
|
+
for (const label of reapable) {
|
|
214
|
+
try {
|
|
215
|
+
const outcome = await provider.deleteLabel(label);
|
|
216
|
+
deleted.push({ label, existed: outcome?.deleted !== false });
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const detail = String(err?.message ?? err);
|
|
219
|
+
failed.push({ label, detail });
|
|
220
|
+
onWarn?.(`could not delete cohort label "${label}": ${detail}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { check: check === true, decisions, reapable, deleted, failed };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The automatic surface's entry point: reap the cohort labels carried by the
|
|
229
|
+
* Story that just closed.
|
|
230
|
+
*
|
|
231
|
+
* The Story's own label set comes from `getTicket` (labels are immutable for
|
|
232
|
+
* this purpose, so a cached snapshot is fine); every *state* judgment comes
|
|
233
|
+
* from the fresh `listIssuesByLabel` read inside {@link decideCohortLabel}, so
|
|
234
|
+
* a primed ticket cache cannot make a still-open sibling look closed.
|
|
235
|
+
*
|
|
236
|
+
* A Story whose own issue has not yet registered as closed — the merge webhook
|
|
237
|
+
* that fires `Closes #<id>` is not instantaneous — simply reports
|
|
238
|
+
* `open-stories` and is left alone. Deferring is the safe direction, and the
|
|
239
|
+
* manual sweep is the backstop that collects whatever a race leaves behind.
|
|
240
|
+
*
|
|
241
|
+
* @param {{
|
|
242
|
+
* storyId: number,
|
|
243
|
+
* provider: object,
|
|
244
|
+
* includeUnreferenced?: boolean,
|
|
245
|
+
* onWarn?: ((message: string) => void)|null,
|
|
246
|
+
* }} args
|
|
247
|
+
* @returns {Promise<object>} the {@link reapCohortLabels} envelope, plus
|
|
248
|
+
* `evaluated` — how many cohort labels the Story carried.
|
|
249
|
+
*/
|
|
250
|
+
export async function reapPlanRunLabelsForStory({
|
|
251
|
+
storyId,
|
|
252
|
+
provider,
|
|
253
|
+
includeUnreferenced = false,
|
|
254
|
+
onWarn = null,
|
|
255
|
+
}) {
|
|
256
|
+
const ticket = await provider.getTicket(storyId);
|
|
257
|
+
const labels = selectCohortLabels(ticket?.labels);
|
|
258
|
+
if (labels.length === 0) {
|
|
259
|
+
return {
|
|
260
|
+
evaluated: 0,
|
|
261
|
+
check: false,
|
|
262
|
+
decisions: [],
|
|
263
|
+
reapable: [],
|
|
264
|
+
deleted: [],
|
|
265
|
+
failed: [],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const outcome = await reapCohortLabels({
|
|
269
|
+
provider,
|
|
270
|
+
labels,
|
|
271
|
+
includeUnreferenced,
|
|
272
|
+
onWarn,
|
|
273
|
+
});
|
|
274
|
+
return { evaluated: labels.length, ...outcome };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The manual surface's entry point: sweep every cohort label in the repository.
|
|
279
|
+
*
|
|
280
|
+
* Reads the whole label vocabulary through the paginating listing port and
|
|
281
|
+
* projects it onto the cohort axis here, so the sweep is bounded by what the
|
|
282
|
+
* repository actually holds rather than by an API page size.
|
|
283
|
+
*
|
|
284
|
+
* @param {{
|
|
285
|
+
* provider: object,
|
|
286
|
+
* includeUnreferenced?: boolean,
|
|
287
|
+
* check?: boolean,
|
|
288
|
+
* onWarn?: ((message: string) => void)|null,
|
|
289
|
+
* }} args
|
|
290
|
+
* @returns {Promise<object>} the {@link reapCohortLabels} envelope, plus
|
|
291
|
+
* `totalLabels` (whole vocabulary) and `evaluated` (the cohort slice).
|
|
292
|
+
*/
|
|
293
|
+
export async function sweepCohortLabels({
|
|
294
|
+
provider,
|
|
295
|
+
includeUnreferenced = false,
|
|
296
|
+
check = false,
|
|
297
|
+
onWarn = null,
|
|
298
|
+
}) {
|
|
299
|
+
const all = await provider.listLabels();
|
|
300
|
+
const rows = Array.isArray(all) ? all : [];
|
|
301
|
+
const labels = selectCohortLabels(rows);
|
|
302
|
+
const outcome = await reapCohortLabels({
|
|
303
|
+
provider,
|
|
304
|
+
labels,
|
|
305
|
+
includeUnreferenced,
|
|
306
|
+
check,
|
|
307
|
+
onWarn,
|
|
308
|
+
});
|
|
309
|
+
return { totalLabels: rows.length, evaluated: labels.length, ...outcome };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Test-only surface. The five helpers below compose the two exported entry
|
|
314
|
+
* points (`reapPlanRunLabelsForStory`, `sweepCohortLabels`) and have no
|
|
315
|
+
* production consumer outside this module, so exporting each one individually
|
|
316
|
+
* would advertise five API surfaces nothing imports — and `dead-exports
|
|
317
|
+
* --production` correctly reports each as dead. They are still worth unit
|
|
318
|
+
* testing per arm, which is what this barrel is for; it follows the same
|
|
319
|
+
* `__testing` idiom `git-probes.js` and `source-classifier.js` use.
|
|
320
|
+
*/
|
|
321
|
+
export const __testing = {
|
|
322
|
+
isPlanRunLabel,
|
|
323
|
+
selectCohortLabels,
|
|
324
|
+
decideCohortLabel,
|
|
325
|
+
evaluateCohortLabels,
|
|
326
|
+
reapCohortLabels,
|
|
327
|
+
};
|
|
@@ -165,7 +165,15 @@ export async function buildAuthoringContext(
|
|
|
165
165
|
() => buildPlanningDocsContext({ seedIssueId: epic.id, settings, cwd }),
|
|
166
166
|
() => verifyBddRunnerPendingTag({ cwd: PROJECT_ROOT }),
|
|
167
167
|
() => scanBddScenariosBestEffort(),
|
|
168
|
-
() =>
|
|
168
|
+
() =>
|
|
169
|
+
buildMemoryPoolAdvisory({
|
|
170
|
+
cwd: PROJECT_ROOT,
|
|
171
|
+
// Story #5182 — `planning.memoryPool` thresholds. Spread so an
|
|
172
|
+
// unset block, or a block setting only one key, leaves the other
|
|
173
|
+
// on its framework default rather than passing `undefined` in as
|
|
174
|
+
// a value the builder would have to re-defaults itself.
|
|
175
|
+
...(opts.memoryPool ?? {}),
|
|
176
|
+
}),
|
|
169
177
|
() =>
|
|
170
178
|
fetchPriorFeedback({
|
|
171
179
|
owner: githubCfg?.owner,
|
|
@@ -19,12 +19,28 @@
|
|
|
19
19
|
* only the attended `/memory-consolidate` pass, reading content, can tell the
|
|
20
20
|
* difference. This module counts and stats; it never judges an entry.
|
|
21
21
|
*
|
|
22
|
+
* **Growth, never size (Story #5182).** The second arm used to be an absolute
|
|
23
|
+
* ceiling of a hundred entries. A consolidation pass prefers `correct` over
|
|
24
|
+
* `dead` by design, so a pool that crosses a fixed ceiling stays over it
|
|
25
|
+
* forever: the nudge then fired on every plan however fresh the stamp, and a
|
|
26
|
+
* permanent recommendation is one the operator learns to ignore. The arm now
|
|
27
|
+
* measures **entries written since the last pass** — the one quantity a pass
|
|
28
|
+
* actually resets, because Step 6 records the post-rewrite entry count in the
|
|
29
|
+
* stamp as the next run's growth baseline.
|
|
30
|
+
*
|
|
31
|
+
* A stamp carrying a date but no usable `entryCount` (every stamp written
|
|
32
|
+
* before that Story) leaves growth **unmeasured**. That is not
|
|
33
|
+
* "never consolidated" — an operator did review the pool — so the growth arm
|
|
34
|
+
* simply stays silent and only the age arm can speak, until the next pass
|
|
35
|
+
* writes a baseline.
|
|
36
|
+
*
|
|
22
37
|
* Detection is filesystem-only — no child processes, no `gh` probes, no
|
|
23
38
|
* network. Every failure path fails soft to "no pool, no recommendation": the
|
|
24
39
|
* advisory can degrade the nudge, never a plan.
|
|
25
40
|
*
|
|
26
41
|
* Test seams: `cwd`, `env`, `fsImpl` (node:fs-compatible `statSync` /
|
|
27
|
-
* `readdirSync` / `readFileSync`), `now`, and the two thresholds
|
|
42
|
+
* `readdirSync` / `readFileSync`), `now`, and the two thresholds
|
|
43
|
+
* (`staleAfterDays`, `growthDelta`).
|
|
28
44
|
*
|
|
29
45
|
* `buildMemoryPoolAdvisory` is the **only** export: the helpers below have no
|
|
30
46
|
* caller outside this module, and exporting one solely for a test would add a
|
|
@@ -40,8 +56,8 @@ import * as path from 'node:path';
|
|
|
40
56
|
/** Recommend a consolidation pass once the stamp is this old. */
|
|
41
57
|
const STALE_AFTER_DAYS = 30;
|
|
42
58
|
|
|
43
|
-
/** Recommend a
|
|
44
|
-
const
|
|
59
|
+
/** Recommend a pass once this many entries were written since the last one. */
|
|
60
|
+
const GROWTH_DELTA = 25;
|
|
45
61
|
|
|
46
62
|
/** Stamp file written by `/memory-consolidate` after its operator gate. */
|
|
47
63
|
const STAMP_FILENAME = '.consolidation-stamp.json';
|
|
@@ -94,21 +110,47 @@ function resolveMemoryPoolDir({ cwd, env = process.env, homedir } = {}) {
|
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
113
|
+
* The growth baseline a stamp records: its entry count, or `null` when it
|
|
114
|
+
* records none. `null` is *unmeasured*, never zero — a zero baseline would
|
|
115
|
+
* score every entry in the pool as newly written.
|
|
116
|
+
*
|
|
117
|
+
* @param {unknown} count
|
|
118
|
+
* @returns {number|null}
|
|
119
|
+
*/
|
|
120
|
+
function readBaseline(count) {
|
|
121
|
+
return Number.isInteger(count) && count >= 0 ? count : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read the consolidation stamp.
|
|
126
|
+
*
|
|
127
|
+
* `at` is the ISO timestamp of the last pass, or `null` when there was none:
|
|
128
|
+
* a missing, unreadable, unparseable or date-less stamp is indistinguishable
|
|
99
129
|
* from "never consolidated" — all four mean the same thing to the advisory.
|
|
130
|
+
* A stamp whose date is unusable carries no baseline either, so `baseline`
|
|
131
|
+
* follows it to `null` rather than describing a pass that cannot be dated.
|
|
132
|
+
*
|
|
133
|
+
* `baseline` is the entry count that pass left behind — the growth arm's
|
|
134
|
+
* reference point. It is `null` on a stamp that predates Story #5182 (date
|
|
135
|
+
* only) and on a malformed count, which reads as *unmeasured growth*, never
|
|
136
|
+
* as zero growth: a `0` baseline would score the whole pool as new.
|
|
100
137
|
*
|
|
101
|
-
* @returns {string|null}
|
|
138
|
+
* @returns {{ at: string|null, baseline: number|null }}
|
|
102
139
|
*/
|
|
103
140
|
function readStamp({ poolDir, fsImpl }) {
|
|
141
|
+
const unstamped = { at: null, baseline: null };
|
|
104
142
|
try {
|
|
105
143
|
const raw = fsImpl.readFileSync(path.join(poolDir, STAMP_FILENAME), 'utf8');
|
|
106
144
|
const parsed = JSON.parse(raw);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
145
|
+
const at = parsed.lastConsolidatedAt;
|
|
146
|
+
// `Date.parse` rejects the empty string as NaN, so this one test covers
|
|
147
|
+
// both an absent date and an unusable one.
|
|
148
|
+
if (typeof at !== 'string' || Number.isNaN(Date.parse(at))) {
|
|
149
|
+
return unstamped;
|
|
150
|
+
}
|
|
151
|
+
return { at, baseline: readBaseline(parsed.entryCount) };
|
|
110
152
|
} catch {
|
|
111
|
-
return
|
|
153
|
+
return unstamped;
|
|
112
154
|
}
|
|
113
155
|
}
|
|
114
156
|
|
|
@@ -127,6 +169,83 @@ function countEntries({ poolDir, fsImpl }) {
|
|
|
127
169
|
}
|
|
128
170
|
}
|
|
129
171
|
|
|
172
|
+
/**
|
|
173
|
+
* The advisory's field set, defaulted to the fail-soft "no usable pool"
|
|
174
|
+
* reading. Every return path spreads its own findings over this, so the
|
|
175
|
+
* envelope's shape is declared once — a new field cannot reach some callers
|
|
176
|
+
* and not others, which is the failure mode a per-branch object literal has.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} fields
|
|
179
|
+
* @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
|
|
180
|
+
* entriesSinceConsolidation: number|null, recommend: boolean,
|
|
181
|
+
* reasons: string[] }}
|
|
182
|
+
*/
|
|
183
|
+
function envelope(fields) {
|
|
184
|
+
return {
|
|
185
|
+
present: false,
|
|
186
|
+
entryCount: 0,
|
|
187
|
+
lastConsolidatedAt: null,
|
|
188
|
+
entriesSinceConsolidation: null,
|
|
189
|
+
recommend: false,
|
|
190
|
+
reasons: [],
|
|
191
|
+
...fields,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Collect the reasons a pool wants a consolidation pass. An empty array is
|
|
197
|
+
* the quiet verdict; the caller turns it into `recommend` and supplies the
|
|
198
|
+
* standing-down sentence, so every arm lives in one place.
|
|
199
|
+
*
|
|
200
|
+
* The two arms are independent and both are reported when both fire.
|
|
201
|
+
*
|
|
202
|
+
* @param {{ stamp: { at: string|null, baseline: number|null },
|
|
203
|
+
* growth: number|null, now: Date|string|number,
|
|
204
|
+
* staleAfterDays: number, growthDelta: number }} args
|
|
205
|
+
* @returns {string[]}
|
|
206
|
+
*/
|
|
207
|
+
function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
|
|
208
|
+
const reasons = [];
|
|
209
|
+
|
|
210
|
+
if (stamp.at === null) {
|
|
211
|
+
reasons.push(
|
|
212
|
+
'no consolidation stamp — this pool has never been consolidated',
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
const ageDays =
|
|
216
|
+
(new Date(now).getTime() - Date.parse(stamp.at)) / MS_PER_DAY;
|
|
217
|
+
if (ageDays > staleAfterDays) {
|
|
218
|
+
reasons.push(
|
|
219
|
+
`last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// `growth === null` is unmeasured, not zero — a pre-#5182 stamp carries no
|
|
225
|
+
// baseline, and guessing one would re-invent the ceiling this arm replaced.
|
|
226
|
+
if (growth !== null && growth >= growthDelta) {
|
|
227
|
+
reasons.push(
|
|
228
|
+
`${growth} entries written since the last consolidation (at or over the ${growthDelta}-entry growth delta)`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return reasons;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The sentence a quiet pool explains itself with — one per reason it is quiet,
|
|
237
|
+
* so "nothing to do" never reads the same as "nothing measurable".
|
|
238
|
+
*
|
|
239
|
+
* @param {{ growth: number|null, growthDelta: number }} args
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
function quietReason({ growth, growthDelta }) {
|
|
243
|
+
if (growth === null) {
|
|
244
|
+
return 'memory pool is within the freshness threshold; growth is unmeasured until the next /memory-consolidate stamps an entry count';
|
|
245
|
+
}
|
|
246
|
+
return `memory pool is within both thresholds — ${growth} entries written since the last consolidation (under the ${growthDelta}-entry growth delta)`;
|
|
247
|
+
}
|
|
248
|
+
|
|
130
249
|
/**
|
|
131
250
|
* Build the `memoryPoolAdvisory` envelope field.
|
|
132
251
|
*
|
|
@@ -141,9 +260,10 @@ function countEntries({ poolDir, fsImpl }) {
|
|
|
141
260
|
* @param {string} [opts.homedir]
|
|
142
261
|
* @param {Date|string|number} [opts.now]
|
|
143
262
|
* @param {number} [opts.staleAfterDays]
|
|
144
|
-
* @param {number} [opts.
|
|
263
|
+
* @param {number} [opts.growthDelta]
|
|
145
264
|
* @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
|
|
146
|
-
*
|
|
265
|
+
* entriesSinceConsolidation: number|null, recommend: boolean,
|
|
266
|
+
* reasons: string[] }}
|
|
147
267
|
*/
|
|
148
268
|
export function buildMemoryPoolAdvisory({
|
|
149
269
|
cwd = process.cwd(),
|
|
@@ -152,15 +272,9 @@ export function buildMemoryPoolAdvisory({
|
|
|
152
272
|
homedir,
|
|
153
273
|
now = new Date(),
|
|
154
274
|
staleAfterDays = STALE_AFTER_DAYS,
|
|
155
|
-
|
|
275
|
+
growthDelta = GROWTH_DELTA,
|
|
156
276
|
} = {}) {
|
|
157
|
-
const absent = (reason) => ({
|
|
158
|
-
present: false,
|
|
159
|
-
entryCount: 0,
|
|
160
|
-
lastConsolidatedAt: null,
|
|
161
|
-
recommend: false,
|
|
162
|
-
reasons: [reason],
|
|
163
|
-
});
|
|
277
|
+
const absent = (reason) => envelope({ reasons: [reason] });
|
|
164
278
|
|
|
165
279
|
const poolDir = resolveMemoryPoolDir({ cwd, env, homedir });
|
|
166
280
|
if (!poolDir) {
|
|
@@ -184,48 +298,38 @@ export function buildMemoryPoolAdvisory({
|
|
|
184
298
|
return absent(`memory pool at ${poolDir} could not be listed`);
|
|
185
299
|
}
|
|
186
300
|
|
|
187
|
-
const
|
|
188
|
-
|
|
301
|
+
const stamp = readStamp({ poolDir, fsImpl });
|
|
302
|
+
// Reported raw: a pruning pass can leave this negative, and saying the pool
|
|
303
|
+
// shrank by 7 is more use to the operator than clamping it to zero.
|
|
304
|
+
const growth = stamp.baseline === null ? null : entryCount - stamp.baseline;
|
|
305
|
+
|
|
306
|
+
const found = {
|
|
307
|
+
present: true,
|
|
308
|
+
entryCount,
|
|
309
|
+
lastConsolidatedAt: stamp.at,
|
|
310
|
+
entriesSinceConsolidation: growth,
|
|
311
|
+
};
|
|
189
312
|
|
|
190
313
|
// An empty pool has nothing to consolidate, whatever the stamp says.
|
|
191
314
|
if (entryCount === 0) {
|
|
192
|
-
return {
|
|
193
|
-
|
|
194
|
-
entryCount: 0,
|
|
195
|
-
lastConsolidatedAt,
|
|
196
|
-
recommend: false,
|
|
315
|
+
return envelope({
|
|
316
|
+
...found,
|
|
197
317
|
reasons: ['memory pool is empty — nothing to consolidate'],
|
|
198
|
-
};
|
|
318
|
+
});
|
|
199
319
|
}
|
|
200
320
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (ageDays > staleAfterDays) {
|
|
209
|
-
reasons.push(
|
|
210
|
-
`last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (entryCount > entryCountCeiling) {
|
|
216
|
-
reasons.push(
|
|
217
|
-
`${entryCount} entries (over the ${entryCountCeiling}-entry threshold)`,
|
|
218
|
-
);
|
|
219
|
-
}
|
|
321
|
+
const reasons = collectReasons({
|
|
322
|
+
stamp,
|
|
323
|
+
growth,
|
|
324
|
+
now,
|
|
325
|
+
staleAfterDays,
|
|
326
|
+
growthDelta,
|
|
327
|
+
});
|
|
220
328
|
|
|
221
|
-
return {
|
|
222
|
-
|
|
223
|
-
entryCount,
|
|
224
|
-
lastConsolidatedAt,
|
|
329
|
+
return envelope({
|
|
330
|
+
...found,
|
|
225
331
|
recommend: reasons.length > 0,
|
|
226
332
|
reasons:
|
|
227
|
-
reasons.length > 0
|
|
228
|
-
|
|
229
|
-
: ['memory pool is within both freshness thresholds'],
|
|
230
|
-
};
|
|
333
|
+
reasons.length > 0 ? reasons : [quietReason({ growth, growthDelta })],
|
|
334
|
+
});
|
|
231
335
|
}
|