@detergent-software/conductor 1.0.0-dev.12 → 1.0.0-dev.13
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 +10 -0
- package/README.md +34 -0
- package/build/cli.js +53 -2
- package/build/cli.js.map +1 -1
- package/build/history.js +630 -0
- package/build/history.js.map +1 -0
- package/build/server.js +23 -0
- package/build/server.js.map +1 -1
- package/build/store.js +66 -4
- package/build/store.js.map +1 -1
- package/package.json +1 -1
package/build/history.js
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConductorError } from "./errors.js";
|
|
3
|
+
import { formatTokens, localDay } from "./spend.js";
|
|
4
|
+
import { ACTIVE_STATUSES } from "./store.js";
|
|
5
|
+
// History and metrics (design note, build step 10): what a window of the log says about a run, as
|
|
6
|
+
// `GET /history`, `conduct history` and `conduct retro`. It reads only what the daemon records
|
|
7
|
+
// already (phase notes, waits, worker events, inbox items, rule events, spend) and writes nothing.
|
|
8
|
+
// The notes that start a phase of work-issue's lifecycle. A phase lasts until the item's next one;
|
|
9
|
+
// Handoff, Progress and Resumed notes fall inside a phase. Landed ends the item.
|
|
10
|
+
export const LIFECYCLE_PHASES = ['Started', 'Done when', 'Repro', 'Plan', 'Plan approved', 'Verified', 'PR opened', 'Landed'];
|
|
11
|
+
const LANDED = 'Landed';
|
|
12
|
+
const FAILURE_KINDS = [
|
|
13
|
+
'worker.launch-failed',
|
|
14
|
+
'worker.resume-failed',
|
|
15
|
+
'worker.park-failed',
|
|
16
|
+
'worker.stopped-unexpectedly',
|
|
17
|
+
'worker.retire-refused',
|
|
18
|
+
'worker.retire-partial',
|
|
19
|
+
'worker.tab-failed',
|
|
20
|
+
'rule.failed',
|
|
21
|
+
'watcher.failed',
|
|
22
|
+
'wait.check-failed',
|
|
23
|
+
'notify.failed',
|
|
24
|
+
'scheduler.read-failed',
|
|
25
|
+
];
|
|
26
|
+
// A slot is held as the scheduler counts it (queue.ts): an active worker that isn't parked.
|
|
27
|
+
const SLOT_EVENTS = {
|
|
28
|
+
'worker.attached': 'slot',
|
|
29
|
+
'worker.launch-failed': 'gone',
|
|
30
|
+
'worker.launched': 'slot',
|
|
31
|
+
'worker.launching': 'slot',
|
|
32
|
+
'worker.parked': 'parked',
|
|
33
|
+
'worker.resumed': 'slot',
|
|
34
|
+
'worker.retired': 'gone',
|
|
35
|
+
'worker.revived': 'slot',
|
|
36
|
+
'worker.running-again': 'slot',
|
|
37
|
+
'worker.stopped-unexpectedly': 'slot',
|
|
38
|
+
};
|
|
39
|
+
// What the manager writes when it ends a worker's waits itself (closeWaits): not a wait that held.
|
|
40
|
+
const CLOSED_RESOLUTIONS = new Set(['launch failed', 'worker retired']);
|
|
41
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
42
|
+
// Text that names a whole day: as a range's end it runs through that day.
|
|
43
|
+
const isDay = (t) => DATE.test(t) || t === 'today' || t === 'yesterday';
|
|
44
|
+
const MINUTE_MS = 60_000;
|
|
45
|
+
const DAY_MS = 24 * 60 * MINUTE_MS;
|
|
46
|
+
const SPAN_UNITS = { d: DAY_MS, h: 60 * MINUTE_MS, m: MINUTE_MS };
|
|
47
|
+
const durations = z.object({ count: z.number(), maxMinutes: z.number(), medianMinutes: z.number(), totalMinutes: z.number() });
|
|
48
|
+
const counted = z.object({ count: z.number(), items: z.array(z.string()), kind: z.string() });
|
|
49
|
+
export const historySchema = z.object({
|
|
50
|
+
failures: z.array(counted),
|
|
51
|
+
// Handoff notes by their kind (jason, npm-otp, merge, blocked …).
|
|
52
|
+
handoffs: z.array(counted),
|
|
53
|
+
inbox: z.array(z.object({ kind: z.string(), open: z.number(), raised: z.number(), resolve: durations, resolvedBy: z.record(z.string(), z.number()) })),
|
|
54
|
+
// Time in each phase, per item, by the item's plan tier ("?" without one).
|
|
55
|
+
phases: z.array(durations.extend({ phase: z.string(), tier: z.string() })),
|
|
56
|
+
// Each plan approved in the window, or still waiting at its end: from its newest Plan note.
|
|
57
|
+
plans: z.array(z.object({ approvedAt: z.string().nullable(), item: z.string(), minutes: z.number(), planAt: z.string(), project: z.string(), revisions: z.number(), tier: z.string() })),
|
|
58
|
+
range: z.object({ project: z.string().nullable(), since: z.string(), until: z.string() }),
|
|
59
|
+
// Items launched more than once in the window, and rule retries (worker.revived).
|
|
60
|
+
retries: z.object({ items: z.array(z.object({ item: z.string(), launches: z.number() })), revived: z.number() }),
|
|
61
|
+
rules: z.array(z.object({ done: z.number(), failed: z.number(), fired: z.number(), ref: z.string() })),
|
|
62
|
+
shipped: z.array(z.object({ item: z.string(), landedAt: z.string(), leadMinutes: z.number().nullable(), project: z.string(), title: z.string().nullable() })),
|
|
63
|
+
slots: z.object({
|
|
64
|
+
average: z.number(),
|
|
65
|
+
// Most running time first: a worker left attached and idle holds its slot all the while.
|
|
66
|
+
byItem: z.array(z.object({ item: z.string(), parkedHours: z.number(), project: z.string(), runningHours: z.number() })),
|
|
67
|
+
byProject: z.array(z.object({ parkedHours: z.number(), project: z.string(), runningHours: z.number() })),
|
|
68
|
+
parkedHours: z.number(),
|
|
69
|
+
peak: z.number(),
|
|
70
|
+
runningHours: z.number(),
|
|
71
|
+
}),
|
|
72
|
+
// Spend is kept per local day, so it covers the whole days the window touches.
|
|
73
|
+
spend: z.object({
|
|
74
|
+
byItem: z.array(z.object({ cacheRead: z.number(), item: z.string(), project: z.string(), tokens: z.number() })),
|
|
75
|
+
byProject: z.array(z.object({ cacheRead: z.number(), project: z.string(), tokens: z.number() })),
|
|
76
|
+
cacheRead: z.number(),
|
|
77
|
+
fromDay: z.string(),
|
|
78
|
+
toDay: z.string(),
|
|
79
|
+
tokens: z.number(),
|
|
80
|
+
}),
|
|
81
|
+
stalls: z.array(counted.extend({ maxMinutes: z.number() })),
|
|
82
|
+
totals: z.object({ daemonStarts: z.number(), items: z.number(), launches: z.number(), notes: z.number() }),
|
|
83
|
+
// Items whose newest lifecycle note at the window's end isn't Landed.
|
|
84
|
+
unfinished: z.array(z.object({ item: z.string(), phase: z.string(), project: z.string(), since: z.string(), title: z.string().nullable() })),
|
|
85
|
+
// Waits registered in the window by kind; open ones count up to the window's end.
|
|
86
|
+
waits: z.array(durations.extend({ closed: z.number(), kind: z.string(), open: z.number() })),
|
|
87
|
+
});
|
|
88
|
+
const noteData = z.looseObject({ kind: z.string().nullish(), phase: z.string(), tier: z.string().nullish() });
|
|
89
|
+
const stallData = z.looseObject({ kind: z.string(), minutes: z.number().optional() });
|
|
90
|
+
const workerData = z.looseObject({ probe: z.boolean().optional(), worker: z.number() });
|
|
91
|
+
// `--since` and `--until`: each a time as parseWhen reads it, and `--until` a day runs through it.
|
|
92
|
+
// Without --since, the window is the 24 hours before its end.
|
|
93
|
+
export function flagWindow(since, until, now) {
|
|
94
|
+
const end = until === undefined ? now : rangeEnd(until, now);
|
|
95
|
+
const start = since === undefined ? new Date(Math.min(end.getTime(), now.getTime()) - DAY_MS) : parseWhen(since, now);
|
|
96
|
+
return checkedWindow({ since: start, until: end }, now, `--since ${since ?? '(24h before the end)'} --until ${until ?? 'now'}`);
|
|
97
|
+
}
|
|
98
|
+
// `conduct history`: the report in a screenful.
|
|
99
|
+
export function historyLines(r) {
|
|
100
|
+
const lines = [
|
|
101
|
+
`history ${windowText(r)}`,
|
|
102
|
+
`items ${r.totals.items} · notes ${r.totals.notes} · launches ${r.totals.launches} · daemon starts ${r.totals.daemonStarts}`,
|
|
103
|
+
`shipped ${r.shipped.length}${r.shipped.length > 0 ? `: ${r.shipped.map((s) => s.item).join(', ')}` : ''}`,
|
|
104
|
+
];
|
|
105
|
+
const approved = r.plans.filter((p) => p.approvedAt !== null);
|
|
106
|
+
const pending = r.plans.filter((p) => p.approvedAt === null);
|
|
107
|
+
const planStats = summarize(approved.map((p) => p.minutes));
|
|
108
|
+
lines.push(`plans: ${approved.length} approved${approved.length > 0 ? `, median ${minutesText(planStats.medianMinutes)}, max ${minutesText(planStats.maxMinutes)}` : ''}` +
|
|
109
|
+
(pending.length > 0 ? `; waiting: ${pending.map((p) => `${p.item} ${minutesText(p.minutes)}`).join(', ')}` : ''));
|
|
110
|
+
lines.push(r.waits.length === 0 ? 'waits: none' : 'waits:');
|
|
111
|
+
r.waits.forEach((w) => lines.push(` ${w.kind.padEnd(8)} ${w.count} · median ${minutesText(w.medianMinutes)} · max ${minutesText(w.maxMinutes)} · total ${minutesText(w.totalMinutes)}${w.open > 0 ? ` · ${w.open} open` : ''}`));
|
|
112
|
+
for (const tier of unique(r.phases.map((p) => p.tier))) {
|
|
113
|
+
lines.push(`phases (tier ${tier}): ${r.phases.filter((p) => p.tier === tier).map((p) => `${p.phase} ${minutesText(p.medianMinutes)}`).join(' · ')} (median per item)`);
|
|
114
|
+
}
|
|
115
|
+
lines.push(`stalls: ${r.stalls.length === 0 ? 'none' : r.stalls.map((s) => `${s.kind} ${s.count} (max ${minutesText(s.maxMinutes)})`).join(', ')}`);
|
|
116
|
+
lines.push(`handoffs: ${r.handoffs.length === 0 ? 'none' : r.handoffs.map((h) => `${h.kind} ${h.count}`).join(', ')}`);
|
|
117
|
+
lines.push(`failures: ${r.failures.length === 0 ? 'none' : r.failures.map((f) => `${f.kind} ${f.count}`).join(', ')}`);
|
|
118
|
+
if (r.retries.items.length > 0 || r.retries.revived > 0) {
|
|
119
|
+
lines.push(`retries: ${[...r.retries.items.map((i) => `${i.item} launched ${i.launches}×`), ...(r.retries.revived > 0 ? [`${r.retries.revived} revived`] : [])].join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
lines.push(`inbox: ${r.inbox.length === 0 ? 'none' : r.inbox.map((i) => `${i.kind} ${i.raised}${i.open > 0 ? ` (${i.open} open)` : ''}`).join(', ')}`);
|
|
122
|
+
lines.push(`slots: peak ${r.slots.peak}, average ${r.slots.average.toFixed(1)}, running ${r.slots.runningHours.toFixed(1)} h, parked ${r.slots.parkedHours.toFixed(1)} h` +
|
|
123
|
+
(r.slots.byItem.length > 0 ? `; longest held: ${r.slots.byItem.slice(0, 3).map((s) => `${s.item} ${s.runningHours.toFixed(1)} h`).join(', ')}` : ''));
|
|
124
|
+
lines.push(`spend (${r.spend.fromDay}..${r.spend.toDay}): ${formatTokens(r.spend.tokens)} tokens + ${formatTokens(r.spend.cacheRead)} cache reads` +
|
|
125
|
+
(r.spend.byItem.length > 0 ? `; most: ${r.spend.byItem.slice(0, 5).map((s) => `${s.item} ${formatTokens(s.tokens)}`).join(', ')}` : ''));
|
|
126
|
+
lines.push(`rules: ${r.rules.length === 0 ? 'none fired' : r.rules.slice(0, 5).map((x) => `${x.ref} ${x.fired}`).join(', ')}`);
|
|
127
|
+
if (r.unfinished.length > 0)
|
|
128
|
+
lines.push(`unfinished: ${r.unfinished.map((u) => `${u.item} (${u.phase})`).join(', ')}`);
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
export function historyReport(store, { project = null, since, until }) {
|
|
132
|
+
const sinceMs = Date.parse(since);
|
|
133
|
+
const untilMs = Date.parse(until);
|
|
134
|
+
const inProject = (p) => project === null || p === project;
|
|
135
|
+
const scope = project === null ? {} : { project };
|
|
136
|
+
const events = store.eventsBetween({ since, until }, scope);
|
|
137
|
+
const titles = store.itemTitles();
|
|
138
|
+
const titleOf = (p, item) => titles.get(key(p, item)) ?? null;
|
|
139
|
+
const workers = store.workersBetween(since, until).filter((w) => !w.probe && inProject(w.project));
|
|
140
|
+
// Every note of the items worked in the window, from the start, so phases and plans that began
|
|
141
|
+
// before it are seen whole.
|
|
142
|
+
const subjects = unique([
|
|
143
|
+
...events.flatMap((e) => (e.kind === 'note.posted' && e.subject ? [e.subject.toUpperCase()] : [])),
|
|
144
|
+
...workers.map((w) => w.item.toUpperCase()),
|
|
145
|
+
]);
|
|
146
|
+
const notes = subjects.length === 0 ? [] : store.eventsBetween({ since: null, until }, { ...scope, kinds: ['note.posted'], subjects }).flatMap(toNote);
|
|
147
|
+
const byItem = groupBy(notes, (n) => key(n.project, n.item));
|
|
148
|
+
const inWindow = (at) => at >= since && at < until;
|
|
149
|
+
const phaseMinutes = new Map();
|
|
150
|
+
const plans = [];
|
|
151
|
+
const shipped = [];
|
|
152
|
+
const unfinished = [];
|
|
153
|
+
// Work on an item stops when its last worker does: an abandoned item's open phase ends there,
|
|
154
|
+
// not at the window's end.
|
|
155
|
+
const endOf = (k) => {
|
|
156
|
+
const own = workers.filter((w) => key(w.project, w.item) === k);
|
|
157
|
+
if (own.length === 0 || own.some((w) => ACTIVE_STATUSES.includes(w.status)))
|
|
158
|
+
return untilMs;
|
|
159
|
+
return Math.min(untilMs, Math.max(...own.map((w) => Date.parse(w.stoppedAt ?? w.updatedAt))));
|
|
160
|
+
};
|
|
161
|
+
for (const [k, itemNotes] of byItem) {
|
|
162
|
+
const lifecycle = itemNotes.filter((n) => LIFECYCLE_PHASES.includes(n.phase));
|
|
163
|
+
const tier = itemNotes.findLast((n) => n.phase === 'Plan' && n.tier)?.tier ?? '?';
|
|
164
|
+
// A note after the last worker stopped means someone carried on: the phase stays open.
|
|
165
|
+
const lastAt = Date.parse(lifecycle.at(-1)?.at ?? since);
|
|
166
|
+
const stopped = endOf(k);
|
|
167
|
+
const end = stopped >= lastAt ? stopped : untilMs;
|
|
168
|
+
const spent = new Map();
|
|
169
|
+
lifecycle.forEach((n, i) => {
|
|
170
|
+
if (n.phase === LANDED)
|
|
171
|
+
return;
|
|
172
|
+
const next = lifecycle[i + 1];
|
|
173
|
+
const minutes = overlapMinutes(Date.parse(n.at), next ? Date.parse(next.at) : end, sinceMs, untilMs);
|
|
174
|
+
if (minutes > 0)
|
|
175
|
+
spent.set(n.phase, (spent.get(n.phase) ?? 0) + minutes);
|
|
176
|
+
});
|
|
177
|
+
for (const [phase, minutes] of spent)
|
|
178
|
+
push(phaseMinutes, `${tier}|${phase}`, minutes);
|
|
179
|
+
let plan = null;
|
|
180
|
+
let revisions = 0;
|
|
181
|
+
for (const n of lifecycle) {
|
|
182
|
+
if (n.phase === 'Plan') {
|
|
183
|
+
if (plan)
|
|
184
|
+
revisions++;
|
|
185
|
+
plan = n;
|
|
186
|
+
}
|
|
187
|
+
else if (n.phase === 'Plan approved' && plan) {
|
|
188
|
+
if (inWindow(n.at))
|
|
189
|
+
plans.push({ approvedAt: n.at, item: n.item, minutes: minutesBetween(plan.at, n.at), planAt: plan.at, project: n.project, revisions, tier });
|
|
190
|
+
plan = null;
|
|
191
|
+
revisions = 0;
|
|
192
|
+
}
|
|
193
|
+
else if (plan) {
|
|
194
|
+
// Any other lifecycle note leaves the plan behind: past it with no Plan approved note (the
|
|
195
|
+
// approval wasn't recorded), or a fresh start (Started, Repro) that abandoned it.
|
|
196
|
+
plan = null;
|
|
197
|
+
revisions = 0;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (plan && end === untilMs) {
|
|
201
|
+
plans.push({ approvedAt: null, item: plan.item, minutes: minutesBetween(plan.at, until), planAt: plan.at, project: plan.project, revisions, tier });
|
|
202
|
+
}
|
|
203
|
+
const last = lifecycle.at(-1);
|
|
204
|
+
const landed = lifecycle.findLast((n) => n.phase === LANDED);
|
|
205
|
+
if (landed && inWindow(landed.at)) {
|
|
206
|
+
const started = lifecycle.find((n) => n.phase === 'Started');
|
|
207
|
+
shipped.push({
|
|
208
|
+
item: landed.item,
|
|
209
|
+
landedAt: landed.at,
|
|
210
|
+
leadMinutes: started ? minutesBetween(started.at, landed.at) : null,
|
|
211
|
+
project: landed.project,
|
|
212
|
+
title: titleOf(landed.project, landed.item),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (last && last.phase !== LANDED) {
|
|
216
|
+
unfinished.push({ item: last.item, phase: last.phase, project: last.project, since: last.at, title: titleOf(last.project, last.item) });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const phases = [...phaseMinutes].map(([k, minutes]) => {
|
|
220
|
+
const [tier = '?', phase = ''] = k.split('|');
|
|
221
|
+
return { phase, tier, ...summarize(minutes) };
|
|
222
|
+
});
|
|
223
|
+
phases.sort((a, b) => a.tier.localeCompare(b.tier) || phaseOrder(a.phase) - phaseOrder(b.phase));
|
|
224
|
+
const windowNotes = events.flatMap((e) => (e.kind === 'note.posted' && inWindow(e.at) ? toNote(e) : []));
|
|
225
|
+
const launches = events.filter((e) => e.kind === 'worker.launching' && workerData.safeParse(e.data).data?.probe !== true);
|
|
226
|
+
return {
|
|
227
|
+
failures: countBy(events.filter((e) => FAILURE_KINDS.includes(e.kind)), (e) => e.kind),
|
|
228
|
+
handoffs: countBy(windowNotes.filter((n) => n.phase === 'Handoff').map((n) => ({ kind: n.kind ?? 'other', subject: n.item })), (n) => n.kind),
|
|
229
|
+
inbox: inboxStats(store, since, until, inProject),
|
|
230
|
+
phases,
|
|
231
|
+
plans: plans.sort((a, b) => a.planAt.localeCompare(b.planAt)),
|
|
232
|
+
range: { project, since, until },
|
|
233
|
+
retries: {
|
|
234
|
+
items: [...groupBy(launches, (e) => (e.subject ?? '').toUpperCase())]
|
|
235
|
+
.filter(([, list]) => list.length > 1)
|
|
236
|
+
.map(([item, list]) => ({ item, launches: list.length })),
|
|
237
|
+
revived: events.filter((e) => e.kind === 'worker.revived').length,
|
|
238
|
+
},
|
|
239
|
+
rules: ruleStats(events),
|
|
240
|
+
shipped: shipped.sort((a, b) => a.landedAt.localeCompare(b.landedAt)),
|
|
241
|
+
slots: slotUse(store, workers, sinceMs, untilMs, until),
|
|
242
|
+
spend: spendStats(store, since, until, inProject),
|
|
243
|
+
stalls: stallStats(events),
|
|
244
|
+
totals: {
|
|
245
|
+
daemonStarts: store.eventsBetween({ since, until }, { kinds: ['daemon.started'] }).length,
|
|
246
|
+
items: unique(windowNotes.map((n) => key(n.project, n.item))).length,
|
|
247
|
+
launches: launches.length,
|
|
248
|
+
notes: windowNotes.length,
|
|
249
|
+
},
|
|
250
|
+
unfinished: unfinished.sort((a, b) => a.since.localeCompare(b.since)),
|
|
251
|
+
waits: waitStats(store.waitsRegisteredBetween(since, until).filter((w) => !w.probe && inProject(w.project)), until),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// 0.5 → <1m, 42 → 42m, 125 → 2h 05m, 1600 → 1d 2h.
|
|
255
|
+
export function minutesText(minutes) {
|
|
256
|
+
if (minutes < 1)
|
|
257
|
+
return '<1m';
|
|
258
|
+
const m = Math.round(minutes);
|
|
259
|
+
if (m < 60)
|
|
260
|
+
return `${m}m`;
|
|
261
|
+
if (m < 24 * 60)
|
|
262
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
|
|
263
|
+
return `${Math.floor(m / (24 * 60))}d ${Math.floor((m % (24 * 60)) / 60)}h`;
|
|
264
|
+
}
|
|
265
|
+
// A window for `conduct history` and `retro`: none (the last 24 hours), `today`, `yesterday`, a date
|
|
266
|
+
// (that day), `A..B` (B a date: through that day), a span such as `24h` or `7d` (up to now), or a
|
|
267
|
+
// date and time (up to now).
|
|
268
|
+
export function parseRange(text, now) {
|
|
269
|
+
return checkedWindow(rangeOf(text?.trim() || '24h', now), now, `"${text ?? ''}"`);
|
|
270
|
+
}
|
|
271
|
+
// One point in time: `now`, `today`, `yesterday`, a date (its start), a date and time (local), or a
|
|
272
|
+
// span back from now such as `90m`, `24h` or `7d`.
|
|
273
|
+
export function parseWhen(text, now) {
|
|
274
|
+
const t = text.trim();
|
|
275
|
+
if (t === 'now')
|
|
276
|
+
return now;
|
|
277
|
+
if (t === 'today')
|
|
278
|
+
return dayStart(now);
|
|
279
|
+
if (t === 'yesterday')
|
|
280
|
+
return addDays(dayStart(now), -1);
|
|
281
|
+
const span = /^(\d+)([mhd])$/.exec(t);
|
|
282
|
+
const unit = SPAN_UNITS[span?.[2] ?? ''];
|
|
283
|
+
if (span && unit !== undefined)
|
|
284
|
+
return new Date(now.getTime() - Number(span[1]) * unit);
|
|
285
|
+
const date = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t);
|
|
286
|
+
if (date)
|
|
287
|
+
return new Date(Number(date[1]), Number(date[2]) - 1, Number(date[3]));
|
|
288
|
+
const at = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(t) ? new Date(t) : null;
|
|
289
|
+
if (!at || Number.isNaN(at.getTime())) {
|
|
290
|
+
throw new ConductorError(`"${text}" is not a time`, ['use today, yesterday, a date (2026-10-10), a date and time (2026-10-10T09:00), or a span such as 24h or 7d']);
|
|
291
|
+
}
|
|
292
|
+
return at;
|
|
293
|
+
}
|
|
294
|
+
// How an inbox item was resolved, from its `resolved_by`.
|
|
295
|
+
export function resolvedHow(by) {
|
|
296
|
+
const command = /\(conduct (\w+)/.exec(by)?.[1];
|
|
297
|
+
if (command)
|
|
298
|
+
return `conduct ${command}`;
|
|
299
|
+
if (/ note$/.test(by))
|
|
300
|
+
return 'a later note';
|
|
301
|
+
if (/ retired$/.test(by))
|
|
302
|
+
return 'worker retired';
|
|
303
|
+
return 'condition seen';
|
|
304
|
+
}
|
|
305
|
+
// `conduct retro`: the report as Markdown, for the console to read or post.
|
|
306
|
+
export function retroMarkdown(r) {
|
|
307
|
+
const out = [`# Conductor retro: ${windowText(r)}`, ''];
|
|
308
|
+
const approved = r.plans.filter((p) => p.approvedAt !== null);
|
|
309
|
+
const planStats = summarize(approved.map((p) => p.minutes));
|
|
310
|
+
const waitTotal = r.waits.reduce((n, w) => n + w.totalMinutes, 0);
|
|
311
|
+
out.push(`- **Shipped:** ${r.shipped.length} of ${r.totals.items} items worked (${r.totals.launches} launches, ${r.totals.notes} phase notes, ${r.totals.daemonStarts} daemon starts).`, `- **Plan latency:** ${approved.length === 0 ? 'no plan approved' : `median ${minutesText(planStats.medianMinutes)}, max ${minutesText(planStats.maxMinutes)} over ${approved.length} plans`}${r.plans.some((p) => p.approvedAt === null) ? `; ${r.plans.filter((p) => p.approvedAt === null).length} still waiting` : ''}.`, `- **Waiting:** ${minutesText(waitTotal)} across ${r.waits.reduce((n, w) => n + w.count, 0)} waits${r.waits[0] ? `, most on ${r.waits[0].kind} (${minutesText(r.waits[0].totalMinutes)})` : ''}.`, `- **Stalls:** ${r.stalls.reduce((n, s) => n + s.count, 0)} stall events, ${r.handoffs.reduce((n, h) => n + h.count, 0)} handoffs, ${r.failures.reduce((n, f) => n + f.count, 0)} failures.`, `- **Spend:** ${formatTokens(r.spend.tokens)} tokens (input, output and cache writes) and ${formatTokens(r.spend.cacheRead)} cache reads, ${r.spend.fromDay} to ${r.spend.toDay}.`, `- **Slots:** peak ${r.slots.peak} running, average ${r.slots.average.toFixed(1)}; ${r.slots.runningHours.toFixed(1)} h running, ${r.slots.parkedHours.toFixed(1)} h parked${r.slots.byItem[0] ? `; longest held by ${r.slots.byItem[0].item} (${r.slots.byItem[0].runningHours.toFixed(1)} h)` : ''}.`, '');
|
|
312
|
+
section(out, 'What shipped', r.shipped.length === 0 ? ['Nothing landed in this window.'] : table(['Item', 'Title', 'Landed', 'Started → Landed'], r.shipped.map((s) => [s.item, s.title ?? '', localTime(s.landedAt), s.leadMinutes === null ? '' : minutesText(s.leadMinutes)])));
|
|
313
|
+
section(out, 'Where time went', r.phases.length === 0 ? ['No lifecycle notes in this window.'] : [
|
|
314
|
+
'Time from each lifecycle note to the item\'s next one, inside the window, per item. A phase is named by the note that starts it: *Plan* is waiting for approval, *Plan approved* is implementing, *Verified* is opening the PR, *PR opened* is review through merge.',
|
|
315
|
+
'',
|
|
316
|
+
...table(['Tier', 'Phase', 'Items', 'Median', 'Max', 'Total'], r.phases.map((p) => [p.tier, p.phase, String(p.count), minutesText(p.medianMinutes), minutesText(p.maxMinutes), minutesText(p.totalMinutes)])),
|
|
317
|
+
]);
|
|
318
|
+
section(out, 'Plan latency', r.plans.length === 0 ? ['No plans in this window.'] : [
|
|
319
|
+
'From the newest Plan note to Plan approved (a revision restarts it).',
|
|
320
|
+
'',
|
|
321
|
+
...table(['Item', 'Tier', 'Plan', 'Approved', 'Waited', 'Revisions'], r.plans.map((p) => [p.item, p.tier, localTime(p.planAt), p.approvedAt === null ? 'still waiting' : localTime(p.approvedAt), minutesText(p.minutes), String(p.revisions)])),
|
|
322
|
+
]);
|
|
323
|
+
section(out, 'Waits', r.waits.length === 0 ? ['No waits registered in this window.'] : [
|
|
324
|
+
'Waits registered in the window, by what they held up. A PR event that resolves several waits counts once.',
|
|
325
|
+
'',
|
|
326
|
+
...table(['Kind', 'Waits', 'Median', 'Max', 'Total', 'Open', 'Closed'], r.waits.map((w) => [w.kind, String(w.count), minutesText(w.medianMinutes), minutesText(w.maxMinutes), minutesText(w.totalMinutes), String(w.open), String(w.closed)])),
|
|
327
|
+
]);
|
|
328
|
+
const stalled = [
|
|
329
|
+
...r.stalls.map((s) => `- Stalled (${s.kind}): ${s.count}× on ${s.items.join(', ')}, longest ${minutesText(s.maxMinutes)}.`),
|
|
330
|
+
...r.handoffs.map((h) => `- Handoff · ${h.kind}: ${h.count}× on ${h.items.join(', ')}.`),
|
|
331
|
+
...r.failures.map((f) => `- ${f.kind}: ${f.count}×${f.items.length > 0 ? ` on ${f.items.join(', ')}` : ''}.`),
|
|
332
|
+
...r.retries.items.map((i) => `- ${i.item} launched ${i.launches} times.`),
|
|
333
|
+
...(r.retries.revived > 0 ? [`- ${r.retries.revived} worker(s) revived by a rule.`] : []),
|
|
334
|
+
...r.unfinished.map((u) => `- Unfinished: ${u.item} in ${u.phase} since ${localTime(u.since)}${u.title ? ` (${u.title})` : ''}.`),
|
|
335
|
+
];
|
|
336
|
+
section(out, 'What stalled', stalled.length === 0 ? ['Nothing stalled, failed or was left unfinished.'] : stalled);
|
|
337
|
+
section(out, 'Inbox', r.inbox.length === 0 ? ['Nothing was raised for Jason.'] : table(['Kind', 'Raised', 'Open', 'Median to resolve', 'Max', 'Resolved by'], r.inbox.map((i) => [
|
|
338
|
+
i.kind,
|
|
339
|
+
String(i.raised),
|
|
340
|
+
String(i.open),
|
|
341
|
+
i.resolve.count === 0 ? '' : minutesText(i.resolve.medianMinutes),
|
|
342
|
+
i.resolve.count === 0 ? '' : minutesText(i.resolve.maxMinutes),
|
|
343
|
+
Object.entries(i.resolvedBy).map(([how, n]) => `${how} ${n}`).join(', '),
|
|
344
|
+
])));
|
|
345
|
+
section(out, 'Rules', r.rules.length === 0 ? ['No rule fired.'] : table(['Rule', 'Fired', 'Done', 'Failed'], r.rules.map((x) => [x.ref, String(x.fired), String(x.done), String(x.failed)])));
|
|
346
|
+
section(out, 'Spend', [
|
|
347
|
+
`Kept per local day, so this covers ${r.spend.fromDay} to ${r.spend.toDay}. Tokens are input, output and cache writes; cache reads (about a tenth of the price) are their own column.`,
|
|
348
|
+
'',
|
|
349
|
+
...table(['Project', 'Tokens', 'Cache reads'], r.spend.byProject.map((p) => [p.project, formatTokens(p.tokens), formatTokens(p.cacheRead)])),
|
|
350
|
+
'',
|
|
351
|
+
...table(['Item', 'Tokens', 'Cache reads'], r.spend.byItem.slice(0, 15).map((s) => [s.item, formatTokens(s.tokens), formatTokens(s.cacheRead)])),
|
|
352
|
+
]);
|
|
353
|
+
section(out, 'Slots', [
|
|
354
|
+
'A slot is held as the scheduler counts it: an active worker that isn\'t parked, including one attached and idle, or stopped unexpectedly and not yet retired.',
|
|
355
|
+
'',
|
|
356
|
+
...table(['Project', 'Running', 'Parked'], r.slots.byProject.map((p) => [p.project, `${p.runningHours.toFixed(1)} h`, `${p.parkedHours.toFixed(1)} h`])),
|
|
357
|
+
'',
|
|
358
|
+
...table(['Item', 'Running', 'Parked'], r.slots.byItem.slice(0, 10).map((s) => [s.item, `${s.runningHours.toFixed(1)} h`, `${s.parkedHours.toFixed(1)} h`])),
|
|
359
|
+
]);
|
|
360
|
+
return `${out.join('\n').trimEnd()}\n`;
|
|
361
|
+
}
|
|
362
|
+
// What a wait held up, from its condition, or for a PR from what resolved it: one PR event often
|
|
363
|
+
// resolves the merged, checks and comment waits together.
|
|
364
|
+
export function waitKind(condition, resolution) {
|
|
365
|
+
const [type = 'other'] = condition.split(':');
|
|
366
|
+
switch (type) {
|
|
367
|
+
case 'approval':
|
|
368
|
+
return 'plan';
|
|
369
|
+
case 'npm':
|
|
370
|
+
return 'npm-otp';
|
|
371
|
+
case 'pr': {
|
|
372
|
+
const outcome = resolution ?? condition;
|
|
373
|
+
if (/\bmerged\b/.test(outcome))
|
|
374
|
+
return 'merge';
|
|
375
|
+
if (/\bchecks\b/.test(outcome))
|
|
376
|
+
return 'checks';
|
|
377
|
+
if (/\bcomment\b/.test(outcome))
|
|
378
|
+
return 'review';
|
|
379
|
+
return 'pr';
|
|
380
|
+
}
|
|
381
|
+
case 'run':
|
|
382
|
+
return 'ci';
|
|
383
|
+
default:
|
|
384
|
+
return type;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function addDays(date, days) {
|
|
388
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, date.getHours(), date.getMinutes());
|
|
389
|
+
}
|
|
390
|
+
// A window can't reach past now (`..today` ends now, not at midnight), and must not be empty.
|
|
391
|
+
function checkedWindow(found, now, what) {
|
|
392
|
+
const range = { since: found.since, until: found.until > now ? now : found.until };
|
|
393
|
+
if (range.since.getTime() >= range.until.getTime())
|
|
394
|
+
throw new ConductorError(`${what} is an empty window: its start is not before its end`);
|
|
395
|
+
return range;
|
|
396
|
+
}
|
|
397
|
+
function countBy(list, kindOf) {
|
|
398
|
+
return [...groupBy(list, kindOf)]
|
|
399
|
+
.map(([kind, all]) => ({ count: all.length, items: unique(all.flatMap((e) => (e.subject ? [e.subject.toUpperCase()] : []))), kind }))
|
|
400
|
+
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
|
401
|
+
}
|
|
402
|
+
function dayStart(date) {
|
|
403
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
404
|
+
}
|
|
405
|
+
function groupBy(list, keyOf) {
|
|
406
|
+
const groups = new Map();
|
|
407
|
+
for (const t of list)
|
|
408
|
+
push(groups, keyOf(t), t);
|
|
409
|
+
return groups;
|
|
410
|
+
}
|
|
411
|
+
function inboxStats(store, since, until, inProject) {
|
|
412
|
+
const items = store.inboxRaisedBetween(since, until).filter((i) => inProject(i.project));
|
|
413
|
+
return [...groupBy(items, (i) => i.kind)]
|
|
414
|
+
.map(([kind, list]) => {
|
|
415
|
+
const resolvedBy = {};
|
|
416
|
+
for (const i of list)
|
|
417
|
+
if (i.resolvedBy)
|
|
418
|
+
resolvedBy[resolvedHow(i.resolvedBy)] = (resolvedBy[resolvedHow(i.resolvedBy)] ?? 0) + 1;
|
|
419
|
+
return {
|
|
420
|
+
kind,
|
|
421
|
+
open: list.filter((i) => i.resolvedAt === null).length,
|
|
422
|
+
raised: list.length,
|
|
423
|
+
resolve: summarize(list.flatMap((i) => (i.resolvedAt ? [minutesBetween(i.raisedAt, i.resolvedAt)] : []))),
|
|
424
|
+
resolvedBy,
|
|
425
|
+
};
|
|
426
|
+
})
|
|
427
|
+
.sort((a, b) => b.raised - a.raised || a.kind.localeCompare(b.kind));
|
|
428
|
+
}
|
|
429
|
+
function key(project, item) {
|
|
430
|
+
return `${project}|${item.toUpperCase()}`;
|
|
431
|
+
}
|
|
432
|
+
function localTime(at) {
|
|
433
|
+
const d = new Date(at);
|
|
434
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
435
|
+
return `${localDay(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
436
|
+
}
|
|
437
|
+
function minutesBetween(from, to) {
|
|
438
|
+
return Math.max(0, (Date.parse(to) - Date.parse(from)) / MINUTE_MS);
|
|
439
|
+
}
|
|
440
|
+
// The minutes of [from, to) that fall inside [since, until).
|
|
441
|
+
function overlapMinutes(from, to, since, until) {
|
|
442
|
+
return Math.max(0, Math.min(to, until) - Math.max(from, since)) / MINUTE_MS;
|
|
443
|
+
}
|
|
444
|
+
function phaseOrder(phase) {
|
|
445
|
+
return LIFECYCLE_PHASES.indexOf(phase);
|
|
446
|
+
}
|
|
447
|
+
function push(map, k, value) {
|
|
448
|
+
const list = map.get(k);
|
|
449
|
+
if (list)
|
|
450
|
+
list.push(value);
|
|
451
|
+
else
|
|
452
|
+
map.set(k, [value]);
|
|
453
|
+
}
|
|
454
|
+
// The end of a range: a day (a date, today, yesterday) runs through that day; a time is itself.
|
|
455
|
+
function rangeEnd(text, now) {
|
|
456
|
+
const t = text.trim();
|
|
457
|
+
const end = parseWhen(t, now);
|
|
458
|
+
return isDay(t) ? addDays(end, 1) : end;
|
|
459
|
+
}
|
|
460
|
+
function rangeOf(t, now) {
|
|
461
|
+
if (t.includes('..')) {
|
|
462
|
+
const [from = '', to = ''] = t.split('..');
|
|
463
|
+
return { since: parseWhen(from, now), until: to.trim() === '' ? now : rangeEnd(to, now) };
|
|
464
|
+
}
|
|
465
|
+
if (t === 'today')
|
|
466
|
+
return { since: dayStart(now), until: now };
|
|
467
|
+
if (t === 'yesterday')
|
|
468
|
+
return { since: addDays(dayStart(now), -1), until: dayStart(now) };
|
|
469
|
+
if (DATE.test(t)) {
|
|
470
|
+
const since = parseWhen(t, now);
|
|
471
|
+
return { since, until: addDays(since, 1) };
|
|
472
|
+
}
|
|
473
|
+
return { since: parseWhen(t, now), until: now };
|
|
474
|
+
}
|
|
475
|
+
function ruleStats(events) {
|
|
476
|
+
const rules = new Map();
|
|
477
|
+
for (const e of events) {
|
|
478
|
+
if (!e.rule || !['rule.done', 'rule.failed', 'rule.fired'].includes(e.kind))
|
|
479
|
+
continue;
|
|
480
|
+
const entry = rules.get(e.rule) ?? { done: 0, failed: 0, fired: 0, ref: e.rule };
|
|
481
|
+
if (e.kind === 'rule.fired')
|
|
482
|
+
entry.fired++;
|
|
483
|
+
else if (e.kind === 'rule.done')
|
|
484
|
+
entry.done++;
|
|
485
|
+
else
|
|
486
|
+
entry.failed++;
|
|
487
|
+
rules.set(e.rule, entry);
|
|
488
|
+
}
|
|
489
|
+
return [...rules.values()].sort((a, b) => b.fired - a.fired || a.ref.localeCompare(b.ref));
|
|
490
|
+
}
|
|
491
|
+
function section(out, title, body) {
|
|
492
|
+
out.push(`## ${title}`, '', ...body, '');
|
|
493
|
+
}
|
|
494
|
+
// Running and parked time, as the scheduler counts slots, integrated over the window.
|
|
495
|
+
function slotUse(store, workers, sinceMs, untilMs, until) {
|
|
496
|
+
const workerOf = new Map(workers.map((w) => [w.id, w]));
|
|
497
|
+
const events = workers.length === 0 ? [] : store.eventsBetween({ since: null, until }, { kinds: Object.keys(SLOT_EVENTS), workers: workers.map((w) => w.id) });
|
|
498
|
+
const state = new Map();
|
|
499
|
+
const byProject = new Map();
|
|
500
|
+
const byItem = new Map();
|
|
501
|
+
const add = (map, k, s, ms) => {
|
|
502
|
+
const entry = map.get(k) ?? { parked: 0, running: 0 };
|
|
503
|
+
if (s === 'slot')
|
|
504
|
+
entry.running += ms;
|
|
505
|
+
else
|
|
506
|
+
entry.parked += ms;
|
|
507
|
+
map.set(k, entry);
|
|
508
|
+
};
|
|
509
|
+
let peak = 0;
|
|
510
|
+
let runningMs = 0;
|
|
511
|
+
let parkedMs = 0;
|
|
512
|
+
let t = sinceMs;
|
|
513
|
+
const advance = (to) => {
|
|
514
|
+
if (to <= t)
|
|
515
|
+
return;
|
|
516
|
+
const running = [...state.values()].filter((s) => s === 'slot').length;
|
|
517
|
+
peak = Math.max(peak, running);
|
|
518
|
+
runningMs += running * (to - t);
|
|
519
|
+
parkedMs += (state.size - running) * (to - t);
|
|
520
|
+
for (const [id, s] of state) {
|
|
521
|
+
const w = workerOf.get(id);
|
|
522
|
+
add(byProject, w?.project ?? '?', s, to - t);
|
|
523
|
+
add(byItem, key(w?.project ?? '?', w?.item ?? '?'), s, to - t);
|
|
524
|
+
}
|
|
525
|
+
t = to;
|
|
526
|
+
};
|
|
527
|
+
for (const e of events) {
|
|
528
|
+
advance(Math.max(Date.parse(e.at), sinceMs));
|
|
529
|
+
const worker = workerData.safeParse(e.data).data?.worker;
|
|
530
|
+
const next = SLOT_EVENTS[e.kind];
|
|
531
|
+
if (worker === undefined || next === undefined)
|
|
532
|
+
continue;
|
|
533
|
+
if (next === 'gone')
|
|
534
|
+
state.delete(worker);
|
|
535
|
+
else
|
|
536
|
+
state.set(worker, next);
|
|
537
|
+
}
|
|
538
|
+
advance(untilMs);
|
|
539
|
+
const toHours = (ms) => Math.round((ms / 3_600_000) * 10) / 10;
|
|
540
|
+
return {
|
|
541
|
+
average: untilMs > sinceMs ? Math.round((runningMs / (untilMs - sinceMs)) * 100) / 100 : 0,
|
|
542
|
+
byItem: [...byItem]
|
|
543
|
+
.map(([k, h]) => {
|
|
544
|
+
const [project = '?', item = '?'] = k.split('|');
|
|
545
|
+
return { item, parkedHours: toHours(h.parked), project, runningHours: toHours(h.running) };
|
|
546
|
+
})
|
|
547
|
+
.sort((a, b) => b.runningHours - a.runningHours || a.item.localeCompare(b.item)),
|
|
548
|
+
byProject: [...byProject].map(([project, h]) => ({ parkedHours: toHours(h.parked), project, runningHours: toHours(h.running) })).sort((a, b) => a.project.localeCompare(b.project)),
|
|
549
|
+
parkedHours: toHours(parkedMs),
|
|
550
|
+
peak,
|
|
551
|
+
runningHours: toHours(runningMs),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function spendStats(store, since, until, inProject) {
|
|
555
|
+
const fromDay = localDay(new Date(since));
|
|
556
|
+
const toDay = localDay(new Date(Date.parse(until) - 1));
|
|
557
|
+
const rows = store.spendBetween(fromDay, toDay).filter((r) => inProject(r.project));
|
|
558
|
+
const tokensOf = (r) => r.input + r.output + r.cacheWrite;
|
|
559
|
+
const sum = (list) => ({
|
|
560
|
+
cacheRead: list.reduce((n, r) => n + r.cacheRead, 0),
|
|
561
|
+
tokens: list.reduce((n, r) => n + tokensOf(r), 0),
|
|
562
|
+
});
|
|
563
|
+
return {
|
|
564
|
+
byItem: [...groupBy(rows, (r) => key(r.project, r.item))]
|
|
565
|
+
.map(([, list]) => ({ item: list[0]?.item.toUpperCase() ?? '', project: list[0]?.project ?? '', ...sum(list) }))
|
|
566
|
+
.sort((a, b) => b.tokens - a.tokens),
|
|
567
|
+
byProject: [...groupBy(rows, (r) => r.project)].map(([p, list]) => ({ project: p, ...sum(list) })).sort((a, b) => b.tokens - a.tokens),
|
|
568
|
+
...sum(rows),
|
|
569
|
+
fromDay,
|
|
570
|
+
toDay,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function stallStats(events) {
|
|
574
|
+
const stalls = events.flatMap((e) => {
|
|
575
|
+
const data = e.kind === 'worker.stalled' ? stallData.safeParse(e.data).data : undefined;
|
|
576
|
+
return data ? [{ kind: data.kind, minutes: data.minutes ?? 0, subject: e.subject }] : [];
|
|
577
|
+
});
|
|
578
|
+
return countBy(stalls, (s) => s.kind).map((c) => ({ ...c, maxMinutes: Math.max(0, ...stalls.filter((s) => s.kind === c.kind).map((s) => s.minutes)) }));
|
|
579
|
+
}
|
|
580
|
+
function summarize(minutes) {
|
|
581
|
+
const sorted = [...minutes].sort((a, b) => a - b);
|
|
582
|
+
const mid = Math.floor(sorted.length / 2);
|
|
583
|
+
const median = sorted.length === 0 ? 0 : sorted.length % 2 === 1 ? (sorted[mid] ?? 0) : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
|
584
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
585
|
+
return { count: sorted.length, maxMinutes: round(sorted.at(-1) ?? 0), medianMinutes: round(median), totalMinutes: round(sorted.reduce((n, m) => n + m, 0)) };
|
|
586
|
+
}
|
|
587
|
+
function table(head, rows) {
|
|
588
|
+
const cell = (s) => s.replaceAll('|', '\\|');
|
|
589
|
+
return [`| ${head.join(' | ')} |`, `| ${head.map(() => '---').join(' | ')} |`, ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`)];
|
|
590
|
+
}
|
|
591
|
+
function toNote(e) {
|
|
592
|
+
const data = noteData.safeParse(e.data).data;
|
|
593
|
+
if (!data || !e.subject || !e.project)
|
|
594
|
+
return [];
|
|
595
|
+
return [{ at: e.at, item: e.subject.toUpperCase(), kind: data.kind ?? null, phase: data.phase, project: e.project, tier: data.tier ?? null }];
|
|
596
|
+
}
|
|
597
|
+
function unique(list) {
|
|
598
|
+
return [...new Set(list)];
|
|
599
|
+
}
|
|
600
|
+
function waitStats(waits, until) {
|
|
601
|
+
const seen = new Set();
|
|
602
|
+
const kept = waits.filter((w) => {
|
|
603
|
+
if (w.resolution === null || CLOSED_RESOLUTIONS.has(w.resolution))
|
|
604
|
+
return true;
|
|
605
|
+
const k = `${w.worker}|${w.resolution}`;
|
|
606
|
+
if (seen.has(k))
|
|
607
|
+
return false;
|
|
608
|
+
seen.add(k);
|
|
609
|
+
return true;
|
|
610
|
+
});
|
|
611
|
+
// A closed wait's resolution says nothing about what it waited on: its condition does.
|
|
612
|
+
return [...groupBy(kept, (w) => waitKind(w.condition, w.resolution !== null && CLOSED_RESOLUTIONS.has(w.resolution) ? null : w.resolution))]
|
|
613
|
+
.map(([kind, list]) => {
|
|
614
|
+
// A wait resolved (or closed) after the window was still open at its end, and counts up to it.
|
|
615
|
+
const endOf = (w) => (w.resolvedAt !== null && w.resolvedAt < until ? w.resolvedAt : until);
|
|
616
|
+
const closed = list.filter((w) => w.resolution !== null && CLOSED_RESOLUTIONS.has(w.resolution) && endOf(w) !== until);
|
|
617
|
+
const timed = list.filter((w) => !closed.includes(w));
|
|
618
|
+
return {
|
|
619
|
+
closed: closed.length,
|
|
620
|
+
kind,
|
|
621
|
+
open: timed.filter((w) => endOf(w) === until).length,
|
|
622
|
+
...summarize(timed.map((w) => minutesBetween(w.createdAt, endOf(w)))),
|
|
623
|
+
};
|
|
624
|
+
})
|
|
625
|
+
.sort((a, b) => b.totalMinutes - a.totalMinutes || a.kind.localeCompare(b.kind));
|
|
626
|
+
}
|
|
627
|
+
function windowText(r) {
|
|
628
|
+
return `${localTime(r.range.since)} → ${localTime(r.range.until)}${r.range.project ? ` (${r.range.project})` : ' (all projects)'}`;
|
|
629
|
+
}
|
|
630
|
+
//# sourceMappingURL=history.js.map
|