@detergent-software/conductor 1.0.1-dev.1 → 1.1.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/CHANGELOG.md +24 -0
- package/README.md +11 -0
- package/build/brief-cli.js.map +1 -1
- package/build/brief.js +24 -9
- package/build/brief.js.map +1 -1
- package/build/broker-cli.js +5 -1
- package/build/broker-cli.js.map +1 -1
- package/build/broker.js +19 -5
- package/build/broker.js.map +1 -1
- package/build/checkout-sync.js +265 -0
- package/build/checkout-sync.js.map +1 -0
- package/build/claude.js +3 -1
- package/build/claude.js.map +1 -1
- package/build/cli.js +114 -31
- package/build/cli.js.map +1 -1
- package/build/client.js +3 -1
- package/build/client.js.map +1 -1
- package/build/conditions.js +4 -2
- package/build/conditions.js.map +1 -1
- package/build/config.js +13 -1
- package/build/config.js.map +1 -1
- package/build/console.js +45 -8
- package/build/console.js.map +1 -1
- package/build/credential.js +4 -1
- package/build/credential.js.map +1 -1
- package/build/daemon.js +56 -7
- package/build/daemon.js.map +1 -1
- package/build/events.js +4 -1
- package/build/events.js.map +1 -1
- package/build/help.js +85 -42
- package/build/help.js.map +1 -1
- package/build/history.js +216 -52
- package/build/history.js.map +1 -1
- package/build/inbox.js +23 -10
- package/build/inbox.js.map +1 -1
- package/build/manager.js +84 -18
- package/build/manager.js.map +1 -1
- package/build/mod.js +3 -1
- package/build/mod.js.map +1 -1
- package/build/note.js +12 -5
- package/build/note.js.map +1 -1
- package/build/notes.js +8 -5
- package/build/notes.js.map +1 -1
- package/build/notify.js +111 -24
- package/build/notify.js.map +1 -1
- package/build/overrides.js +24 -6
- package/build/overrides.js.map +1 -1
- package/build/profile.js.map +1 -1
- package/build/pushover.js +4 -1
- package/build/pushover.js.map +1 -1
- package/build/queue.js +18 -3
- package/build/queue.js.map +1 -1
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +173 -36
- package/build/rule-actions.js.map +1 -1
- package/build/rule-input.js +3 -1
- package/build/rule-input.js.map +1 -1
- package/build/rules-service.js +41 -11
- package/build/rules-service.js.map +1 -1
- package/build/rules.js +86 -14
- package/build/rules.js.map +1 -1
- package/build/scheduler.js +48 -8
- package/build/scheduler.js.map +1 -1
- package/build/server.js +40 -10
- package/build/server.js.map +1 -1
- package/build/spend.js.map +1 -1
- package/build/store.js +71 -16
- package/build/store.js.map +1 -1
- package/build/token.js +3 -1
- package/build/token.js.map +1 -1
- package/build/trackers.js +3 -1
- package/build/trackers.js.map +1 -1
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +3 -1
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +5 -2
- package/build/watchers/ado-api.js.map +1 -1
- package/build/watchers/ado.js +18 -6
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-brief.js +9 -3
- package/build/watchers/boards-brief.js.map +1 -1
- package/build/watchers/boards-queue.js +10 -5
- package/build/watchers/boards-queue.js.map +1 -1
- package/build/watchers/boards-writes.js +11 -3
- package/build/watchers/boards-writes.js.map +1 -1
- package/build/watchers/boards.js +12 -3
- package/build/watchers/boards.js.map +1 -1
- package/build/watchers/conflicts.js +11 -3
- package/build/watchers/conflicts.js.map +1 -1
- package/build/watchers/github.js +6 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/health.js.map +1 -1
- package/build/watchers/index.js +22 -4
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +1 -1
- package/build/watchers/linear-api.js.map +1 -1
- package/build/watchers/linear-labels.js +3 -1
- package/build/watchers/linear-labels.js.map +1 -1
- package/build/watchers/linear-queue.js +10 -4
- package/build/watchers/linear-queue.js.map +1 -1
- package/build/watchers/linear.js +13 -4
- package/build/watchers/linear.js.map +1 -1
- package/build/watchers/npm.js.map +1 -1
- package/build/watchers/source.js +1 -1
- package/build/watchers/source.js.map +1 -1
- package/build/why.js +7 -3
- package/build/why.js.map +1 -1
- package/build/worktrees.js +2 -1
- package/build/worktrees.js.map +1 -1
- package/mod/hooks/model.ts +90 -25
- package/mod/hooks/register.tsx +172 -62
- package/package.json +11 -12
package/build/history.js
CHANGED
|
@@ -7,7 +7,16 @@ import { ACTIVE_STATUSES } from "./store.js";
|
|
|
7
7
|
// already (phase notes, waits, worker events, inbox items, rule events, spend) and writes nothing.
|
|
8
8
|
// The notes that start a phase of work-issue's lifecycle. A phase lasts until the item's next one;
|
|
9
9
|
// Handoff, Progress and Resumed notes fall inside a phase. Landed ends the item.
|
|
10
|
-
export const LIFECYCLE_PHASES = [
|
|
10
|
+
export const LIFECYCLE_PHASES = [
|
|
11
|
+
'Started',
|
|
12
|
+
'Done when',
|
|
13
|
+
'Repro',
|
|
14
|
+
'Plan',
|
|
15
|
+
'Plan approved',
|
|
16
|
+
'Verified',
|
|
17
|
+
'PR opened',
|
|
18
|
+
'Landed',
|
|
19
|
+
];
|
|
11
20
|
const LANDED = 'Landed';
|
|
12
21
|
const FAILURE_KINDS = [
|
|
13
22
|
'worker.launch-failed',
|
|
@@ -48,22 +57,47 @@ const isDay = (t) => DATE.test(t) || t === 'today' || t === 'yesterday';
|
|
|
48
57
|
const MINUTE_MS = 60_000;
|
|
49
58
|
const DAY_MS = 24 * 60 * MINUTE_MS;
|
|
50
59
|
const SPAN_UNITS = { d: DAY_MS, h: 60 * MINUTE_MS, m: MINUTE_MS };
|
|
51
|
-
const durations = z.object({
|
|
60
|
+
const durations = z.object({
|
|
61
|
+
count: z.number(),
|
|
62
|
+
maxMinutes: z.number(),
|
|
63
|
+
medianMinutes: z.number(),
|
|
64
|
+
totalMinutes: z.number(),
|
|
65
|
+
});
|
|
52
66
|
const counted = z.object({ count: z.number(), items: z.array(z.string()), kind: z.string() });
|
|
53
67
|
export const historySchema = z.object({
|
|
54
68
|
failures: z.array(counted),
|
|
55
69
|
// Handoff notes by their kind (jason, npm-otp, merge, blocked …).
|
|
56
70
|
handoffs: z.array(counted),
|
|
57
|
-
inbox: z.array(z.object({
|
|
71
|
+
inbox: z.array(z.object({
|
|
72
|
+
kind: z.string(),
|
|
73
|
+
open: z.number(),
|
|
74
|
+
raised: z.number(),
|
|
75
|
+
resolve: durations,
|
|
76
|
+
resolvedBy: z.record(z.string(), z.number()),
|
|
77
|
+
})),
|
|
58
78
|
// Time in each phase, per item, by the item's plan tier ("?" without one).
|
|
59
79
|
phases: z.array(durations.extend({ phase: z.string(), tier: z.string() })),
|
|
60
80
|
// Each plan approved in the window, or still waiting at its end: from its newest Plan note.
|
|
61
|
-
plans: z.array(z.object({
|
|
81
|
+
plans: z.array(z.object({
|
|
82
|
+
approvedAt: z.string().nullable(),
|
|
83
|
+
item: z.string(),
|
|
84
|
+
minutes: z.number(),
|
|
85
|
+
planAt: z.string(),
|
|
86
|
+
project: z.string(),
|
|
87
|
+
revisions: z.number(),
|
|
88
|
+
tier: z.string(),
|
|
89
|
+
})),
|
|
62
90
|
range: z.object({ project: z.string().nullable(), since: z.string(), until: z.string() }),
|
|
63
91
|
// Items launched more than once in the window, and rule retries (worker.revived).
|
|
64
92
|
retries: z.object({ items: z.array(z.object({ item: z.string(), launches: z.number() })), revived: z.number() }),
|
|
65
93
|
rules: z.array(z.object({ done: z.number(), failed: z.number(), fired: z.number(), ref: z.string() })),
|
|
66
|
-
shipped: z.array(z.object({
|
|
94
|
+
shipped: z.array(z.object({
|
|
95
|
+
item: z.string(),
|
|
96
|
+
landedAt: z.string(),
|
|
97
|
+
leadMinutes: z.number().nullable(),
|
|
98
|
+
project: z.string(),
|
|
99
|
+
title: z.string().nullable(),
|
|
100
|
+
})),
|
|
67
101
|
slots: z.object({
|
|
68
102
|
average: z.number(),
|
|
69
103
|
// Most running time first: a worker left attached and idle holds its slot all the while.
|
|
@@ -85,7 +119,13 @@ export const historySchema = z.object({
|
|
|
85
119
|
stalls: z.array(counted.extend({ maxMinutes: z.number() })),
|
|
86
120
|
totals: z.object({ daemonStarts: z.number(), items: z.number(), launches: z.number(), notes: z.number() }),
|
|
87
121
|
// Items whose newest lifecycle note at the window's end isn't Landed.
|
|
88
|
-
unfinished: z.array(z.object({
|
|
122
|
+
unfinished: z.array(z.object({
|
|
123
|
+
item: z.string(),
|
|
124
|
+
phase: z.string(),
|
|
125
|
+
project: z.string(),
|
|
126
|
+
since: z.string(),
|
|
127
|
+
title: z.string().nullable(),
|
|
128
|
+
})),
|
|
89
129
|
// Waits registered in the window by kind; open ones count up to the window's end.
|
|
90
130
|
waits: z.array(durations.extend({ closed: z.number(), kind: z.string(), open: z.number() })),
|
|
91
131
|
});
|
|
@@ -114,7 +154,10 @@ export function historyLines(r) {
|
|
|
114
154
|
lines.push(r.waits.length === 0 ? 'waits: none' : 'waits:');
|
|
115
155
|
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` : ''}`));
|
|
116
156
|
for (const tier of unique(r.phases.map((p) => p.tier))) {
|
|
117
|
-
lines.push(`phases (tier ${tier}): ${r.phases
|
|
157
|
+
lines.push(`phases (tier ${tier}): ${r.phases
|
|
158
|
+
.filter((p) => p.tier === tier)
|
|
159
|
+
.map((p) => `${p.phase} ${minutesText(p.medianMinutes)}`)
|
|
160
|
+
.join(' · ')} (median per item)`);
|
|
118
161
|
}
|
|
119
162
|
lines.push(`stalls: ${r.stalls.length === 0 ? 'none' : r.stalls.map((s) => `${s.kind} ${s.count} (max ${minutesText(s.maxMinutes)})`).join(', ')}`);
|
|
120
163
|
lines.push(`handoffs: ${r.handoffs.length === 0 ? 'none' : r.handoffs.map((h) => `${h.kind} ${h.count}`).join(', ')}`);
|
|
@@ -124,10 +167,25 @@ export function historyLines(r) {
|
|
|
124
167
|
}
|
|
125
168
|
lines.push(`inbox: ${r.inbox.length === 0 ? 'none' : r.inbox.map((i) => `${i.kind} ${i.raised}${i.open > 0 ? ` (${i.open} open)` : ''}`).join(', ')}`);
|
|
126
169
|
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` +
|
|
127
|
-
(r.slots.byItem.length > 0
|
|
170
|
+
(r.slots.byItem.length > 0
|
|
171
|
+
? `; longest held: ${r.slots.byItem
|
|
172
|
+
.slice(0, 3)
|
|
173
|
+
.map((s) => `${s.item} ${s.runningHours.toFixed(1)} h`)
|
|
174
|
+
.join(', ')}`
|
|
175
|
+
: ''));
|
|
128
176
|
lines.push(`spend (${r.spend.fromDay}..${r.spend.toDay}): ${formatTokens(r.spend.tokens)} tokens + ${formatTokens(r.spend.cacheRead)} cache reads` +
|
|
129
|
-
(r.spend.byItem.length > 0
|
|
130
|
-
|
|
177
|
+
(r.spend.byItem.length > 0
|
|
178
|
+
? `; most: ${r.spend.byItem
|
|
179
|
+
.slice(0, 5)
|
|
180
|
+
.map((s) => `${s.item} ${formatTokens(s.tokens)}`)
|
|
181
|
+
.join(', ')}`
|
|
182
|
+
: ''));
|
|
183
|
+
lines.push(`rules: ${r.rules.length === 0
|
|
184
|
+
? 'none fired'
|
|
185
|
+
: r.rules
|
|
186
|
+
.slice(0, 5)
|
|
187
|
+
.map((x) => `${x.ref} ${x.fired}`)
|
|
188
|
+
.join(', ')}`);
|
|
131
189
|
if (r.unfinished.length > 0)
|
|
132
190
|
lines.push(`unfinished: ${r.unfinished.map((u) => `${u.item} (${u.phase})`).join(', ')}`);
|
|
133
191
|
return lines;
|
|
@@ -147,7 +205,9 @@ export function historyReport(store, { project = null, since, until }) {
|
|
|
147
205
|
...events.flatMap((e) => (e.kind === 'note.posted' && e.subject ? [e.subject.toUpperCase()] : [])),
|
|
148
206
|
...workers.map((w) => w.item.toUpperCase()),
|
|
149
207
|
]);
|
|
150
|
-
const notes = subjects.length === 0
|
|
208
|
+
const notes = subjects.length === 0
|
|
209
|
+
? []
|
|
210
|
+
: store.eventsBetween({ since: null, until }, { ...scope, kinds: ['note.posted'], subjects }).flatMap(toNote);
|
|
151
211
|
const byItem = groupBy(notes, (n) => key(n.project, n.item));
|
|
152
212
|
const inWindow = (at) => at >= since && at < until;
|
|
153
213
|
const phaseMinutes = new Map();
|
|
@@ -190,7 +250,15 @@ export function historyReport(store, { project = null, since, until }) {
|
|
|
190
250
|
}
|
|
191
251
|
else if (n.phase === 'Plan approved' && plan) {
|
|
192
252
|
if (inWindow(n.at))
|
|
193
|
-
plans.push({
|
|
253
|
+
plans.push({
|
|
254
|
+
approvedAt: n.at,
|
|
255
|
+
item: n.item,
|
|
256
|
+
minutes: minutesBetween(plan.at, n.at),
|
|
257
|
+
planAt: plan.at,
|
|
258
|
+
project: n.project,
|
|
259
|
+
revisions,
|
|
260
|
+
tier,
|
|
261
|
+
});
|
|
194
262
|
plan = null;
|
|
195
263
|
revisions = 0;
|
|
196
264
|
}
|
|
@@ -202,7 +270,15 @@ export function historyReport(store, { project = null, since, until }) {
|
|
|
202
270
|
}
|
|
203
271
|
}
|
|
204
272
|
if (plan && end === untilMs) {
|
|
205
|
-
plans.push({
|
|
273
|
+
plans.push({
|
|
274
|
+
approvedAt: null,
|
|
275
|
+
item: plan.item,
|
|
276
|
+
minutes: minutesBetween(plan.at, until),
|
|
277
|
+
planAt: plan.at,
|
|
278
|
+
project: plan.project,
|
|
279
|
+
revisions,
|
|
280
|
+
tier,
|
|
281
|
+
});
|
|
206
282
|
}
|
|
207
283
|
const last = lifecycle.at(-1);
|
|
208
284
|
const landed = lifecycle.findLast((n) => n.phase === LANDED);
|
|
@@ -217,7 +293,13 @@ export function historyReport(store, { project = null, since, until }) {
|
|
|
217
293
|
});
|
|
218
294
|
}
|
|
219
295
|
if (last && last.phase !== LANDED) {
|
|
220
|
-
unfinished.push({
|
|
296
|
+
unfinished.push({
|
|
297
|
+
item: last.item,
|
|
298
|
+
phase: last.phase,
|
|
299
|
+
project: last.project,
|
|
300
|
+
since: last.at,
|
|
301
|
+
title: titleOf(last.project, last.item),
|
|
302
|
+
});
|
|
221
303
|
}
|
|
222
304
|
}
|
|
223
305
|
const phases = [...phaseMinutes].map(([k, minutes]) => {
|
|
@@ -291,7 +373,9 @@ export function parseWhen(text, now) {
|
|
|
291
373
|
return new Date(Number(date[1]), Number(date[2]) - 1, Number(date[3]));
|
|
292
374
|
const at = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(t) ? new Date(t) : null;
|
|
293
375
|
if (!at || Number.isNaN(at.getTime())) {
|
|
294
|
-
throw new ConductorError(`"${text}" is not a time`, [
|
|
376
|
+
throw new ConductorError(`"${text}" is not a time`, [
|
|
377
|
+
'use today, yesterday, a date (2026-10-10), a date and time (2026-10-10T09:00), or a span such as 24h or 7d',
|
|
378
|
+
]);
|
|
295
379
|
}
|
|
296
380
|
return at;
|
|
297
381
|
}
|
|
@@ -313,22 +397,57 @@ export function retroMarkdown(r) {
|
|
|
313
397
|
const planStats = summarize(approved.map((p) => p.minutes));
|
|
314
398
|
const waitTotal = r.waits.reduce((n, w) => n + w.totalMinutes, 0);
|
|
315
399
|
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)` : ''}.`, '');
|
|
316
|
-
section(out, 'What shipped', r.shipped.length === 0
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
400
|
+
section(out, 'What shipped', r.shipped.length === 0
|
|
401
|
+
? ['Nothing landed in this window.']
|
|
402
|
+
: table(['Item', 'Title', 'Landed', 'Started → Landed'], r.shipped.map((s) => [
|
|
403
|
+
s.item,
|
|
404
|
+
s.title ?? '',
|
|
405
|
+
localTime(s.landedAt),
|
|
406
|
+
s.leadMinutes === null ? '' : minutesText(s.leadMinutes),
|
|
407
|
+
])));
|
|
408
|
+
section(out, 'Where time went', r.phases.length === 0
|
|
409
|
+
? ['No lifecycle notes in this window.']
|
|
410
|
+
: [
|
|
411
|
+
"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.",
|
|
412
|
+
'',
|
|
413
|
+
...table(['Tier', 'Phase', 'Items', 'Median', 'Max', 'Total'], r.phases.map((p) => [
|
|
414
|
+
p.tier,
|
|
415
|
+
p.phase,
|
|
416
|
+
String(p.count),
|
|
417
|
+
minutesText(p.medianMinutes),
|
|
418
|
+
minutesText(p.maxMinutes),
|
|
419
|
+
minutesText(p.totalMinutes),
|
|
420
|
+
])),
|
|
421
|
+
]);
|
|
422
|
+
section(out, 'Plan latency', r.plans.length === 0
|
|
423
|
+
? ['No plans in this window.']
|
|
424
|
+
: [
|
|
425
|
+
'From the newest Plan note to Plan approved (a revision restarts it).',
|
|
426
|
+
'',
|
|
427
|
+
...table(['Item', 'Tier', 'Plan', 'Approved', 'Waited', 'Revisions'], r.plans.map((p) => [
|
|
428
|
+
p.item,
|
|
429
|
+
p.tier,
|
|
430
|
+
localTime(p.planAt),
|
|
431
|
+
p.approvedAt === null ? 'still waiting' : localTime(p.approvedAt),
|
|
432
|
+
minutesText(p.minutes),
|
|
433
|
+
String(p.revisions),
|
|
434
|
+
])),
|
|
435
|
+
]);
|
|
436
|
+
section(out, 'Waits', r.waits.length === 0
|
|
437
|
+
? ['No waits registered in this window.']
|
|
438
|
+
: [
|
|
439
|
+
'Waits registered in the window, by what they held up. A PR event that resolves several waits counts once.',
|
|
440
|
+
'',
|
|
441
|
+
...table(['Kind', 'Waits', 'Median', 'Max', 'Total', 'Open', 'Closed'], r.waits.map((w) => [
|
|
442
|
+
w.kind,
|
|
443
|
+
String(w.count),
|
|
444
|
+
minutesText(w.medianMinutes),
|
|
445
|
+
minutesText(w.maxMinutes),
|
|
446
|
+
minutesText(w.totalMinutes),
|
|
447
|
+
String(w.open),
|
|
448
|
+
String(w.closed),
|
|
449
|
+
])),
|
|
450
|
+
]);
|
|
332
451
|
const stalled = [
|
|
333
452
|
...r.stalls.map((s) => `- Stalled (${s.kind}): ${s.count}× on ${s.items.join(', ')}, longest ${minutesText(s.maxMinutes)}.`),
|
|
334
453
|
...r.handoffs.map((h) => `- Handoff · ${h.kind}: ${h.count}× on ${h.items.join(', ')}.`),
|
|
@@ -338,15 +457,21 @@ export function retroMarkdown(r) {
|
|
|
338
457
|
...r.unfinished.map((u) => `- Unfinished: ${u.item} in ${u.phase} since ${localTime(u.since)}${u.title ? ` (${u.title})` : ''}.`),
|
|
339
458
|
];
|
|
340
459
|
section(out, 'What stalled', stalled.length === 0 ? ['Nothing stalled, failed or was left unfinished.'] : stalled);
|
|
341
|
-
section(out, 'Inbox', r.inbox.length === 0
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
460
|
+
section(out, 'Inbox', r.inbox.length === 0
|
|
461
|
+
? ['Nothing was raised for Jason.']
|
|
462
|
+
: table(['Kind', 'Raised', 'Open', 'Median to resolve', 'Max', 'Resolved by'], r.inbox.map((i) => [
|
|
463
|
+
i.kind,
|
|
464
|
+
String(i.raised),
|
|
465
|
+
String(i.open),
|
|
466
|
+
i.resolve.count === 0 ? '' : minutesText(i.resolve.medianMinutes),
|
|
467
|
+
i.resolve.count === 0 ? '' : minutesText(i.resolve.maxMinutes),
|
|
468
|
+
Object.entries(i.resolvedBy)
|
|
469
|
+
.map(([how, n]) => `${how} ${n}`)
|
|
470
|
+
.join(', '),
|
|
471
|
+
])));
|
|
472
|
+
section(out, 'Rules', r.rules.length === 0
|
|
473
|
+
? ['No rule fired.']
|
|
474
|
+
: table(['Rule', 'Fired', 'Done', 'Failed'], r.rules.map((x) => [x.ref, String(x.fired), String(x.done), String(x.failed)])));
|
|
350
475
|
section(out, 'Spend', [
|
|
351
476
|
`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.`,
|
|
352
477
|
'',
|
|
@@ -355,11 +480,13 @@ export function retroMarkdown(r) {
|
|
|
355
480
|
...table(['Item', 'Tokens', 'Cache reads'], r.spend.byItem.slice(0, 15).map((s) => [s.item, formatTokens(s.tokens), formatTokens(s.cacheRead)])),
|
|
356
481
|
]);
|
|
357
482
|
section(out, 'Slots', [
|
|
358
|
-
|
|
483
|
+
"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.",
|
|
359
484
|
'',
|
|
360
485
|
...table(['Project', 'Running', 'Parked'], r.slots.byProject.map((p) => [p.project, `${p.runningHours.toFixed(1)} h`, `${p.parkedHours.toFixed(1)} h`])),
|
|
361
486
|
'',
|
|
362
|
-
...table(['Item', 'Running', 'Parked'], r.slots.byItem
|
|
487
|
+
...table(['Item', 'Running', 'Parked'], r.slots.byItem
|
|
488
|
+
.slice(0, 10)
|
|
489
|
+
.map((s) => [s.item, `${s.runningHours.toFixed(1)} h`, `${s.parkedHours.toFixed(1)} h`])),
|
|
363
490
|
]);
|
|
364
491
|
return `${out.join('\n').trimEnd()}\n`;
|
|
365
492
|
}
|
|
@@ -403,7 +530,11 @@ function checkedWindow(found, now, what) {
|
|
|
403
530
|
}
|
|
404
531
|
function countBy(list, kindOf) {
|
|
405
532
|
return [...groupBy(list, kindOf)]
|
|
406
|
-
.map(([kind, all]) => ({
|
|
533
|
+
.map(([kind, all]) => ({
|
|
534
|
+
count: all.length,
|
|
535
|
+
items: unique(all.flatMap((e) => (e.subject ? [e.subject.toUpperCase()] : []))),
|
|
536
|
+
kind,
|
|
537
|
+
}))
|
|
407
538
|
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
|
408
539
|
}
|
|
409
540
|
function dayStart(date) {
|
|
@@ -501,7 +632,9 @@ function section(out, title, body) {
|
|
|
501
632
|
// Running and parked time, as the scheduler counts slots, integrated over the window.
|
|
502
633
|
function slotUse(store, workers, sinceMs, untilMs, until) {
|
|
503
634
|
const workerOf = new Map(workers.map((w) => [w.id, w]));
|
|
504
|
-
const events = workers.length === 0
|
|
635
|
+
const events = workers.length === 0
|
|
636
|
+
? []
|
|
637
|
+
: store.eventsBetween({ since: null, until }, { kinds: Object.keys(SLOT_EVENTS), workers: workers.map((w) => w.id) });
|
|
505
638
|
const state = new Map();
|
|
506
639
|
const byProject = new Map();
|
|
507
640
|
const byItem = new Map();
|
|
@@ -552,7 +685,9 @@ function slotUse(store, workers, sinceMs, untilMs, until) {
|
|
|
552
685
|
return { item, parkedHours: toHours(h.parked), project, runningHours: toHours(h.running) };
|
|
553
686
|
})
|
|
554
687
|
.sort((a, b) => b.runningHours - a.runningHours || a.item.localeCompare(b.item)),
|
|
555
|
-
byProject: [...byProject]
|
|
688
|
+
byProject: [...byProject]
|
|
689
|
+
.map(([project, h]) => ({ parkedHours: toHours(h.parked), project, runningHours: toHours(h.running) }))
|
|
690
|
+
.sort((a, b) => a.project.localeCompare(b.project)),
|
|
556
691
|
parkedHours: toHours(parkedMs),
|
|
557
692
|
peak,
|
|
558
693
|
runningHours: toHours(runningMs),
|
|
@@ -571,7 +706,9 @@ function spendStats(store, since, until, inProject) {
|
|
|
571
706
|
byItem: [...groupBy(rows, (r) => key(r.project, r.item))]
|
|
572
707
|
.map(([, list]) => ({ item: list[0]?.item.toUpperCase() ?? '', project: list[0]?.project ?? '', ...sum(list) }))
|
|
573
708
|
.sort((a, b) => b.tokens - a.tokens),
|
|
574
|
-
byProject: [...groupBy(rows, (r) => r.project)]
|
|
709
|
+
byProject: [...groupBy(rows, (r) => r.project)]
|
|
710
|
+
.map(([p, list]) => ({ project: p, ...sum(list) }))
|
|
711
|
+
.sort((a, b) => b.tokens - a.tokens),
|
|
575
712
|
...sum(rows),
|
|
576
713
|
fromDay,
|
|
577
714
|
toDay,
|
|
@@ -582,24 +719,49 @@ function stallStats(events) {
|
|
|
582
719
|
const data = e.kind === 'worker.stalled' ? stallData.safeParse(e.data).data : undefined;
|
|
583
720
|
return data ? [{ kind: data.kind, minutes: data.minutes ?? 0, subject: e.subject }] : [];
|
|
584
721
|
});
|
|
585
|
-
return countBy(stalls, (s) => s.kind).map((c) => ({
|
|
722
|
+
return countBy(stalls, (s) => s.kind).map((c) => ({
|
|
723
|
+
...c,
|
|
724
|
+
maxMinutes: Math.max(0, ...stalls.filter((s) => s.kind === c.kind).map((s) => s.minutes)),
|
|
725
|
+
}));
|
|
586
726
|
}
|
|
587
727
|
function summarize(minutes) {
|
|
588
728
|
const sorted = [...minutes].sort((a, b) => a - b);
|
|
589
729
|
const mid = Math.floor(sorted.length / 2);
|
|
590
|
-
const median = sorted.length === 0
|
|
730
|
+
const median = sorted.length === 0
|
|
731
|
+
? 0
|
|
732
|
+
: sorted.length % 2 === 1
|
|
733
|
+
? (sorted[mid] ?? 0)
|
|
734
|
+
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
|
591
735
|
const round = (n) => Math.round(n * 10) / 10;
|
|
592
|
-
return {
|
|
736
|
+
return {
|
|
737
|
+
count: sorted.length,
|
|
738
|
+
maxMinutes: round(sorted.at(-1) ?? 0),
|
|
739
|
+
medianMinutes: round(median),
|
|
740
|
+
totalMinutes: round(sorted.reduce((n, m) => n + m, 0)),
|
|
741
|
+
};
|
|
593
742
|
}
|
|
594
743
|
function table(head, rows) {
|
|
595
744
|
const cell = (s) => s.replaceAll('|', '\\|');
|
|
596
|
-
return [
|
|
745
|
+
return [
|
|
746
|
+
`| ${head.join(' | ')} |`,
|
|
747
|
+
`| ${head.map(() => '---').join(' | ')} |`,
|
|
748
|
+
...rows.map((r) => `| ${r.map(cell).join(' | ')} |`),
|
|
749
|
+
];
|
|
597
750
|
}
|
|
598
751
|
function toNote(e) {
|
|
599
752
|
const data = noteData.safeParse(e.data).data;
|
|
600
753
|
if (!data || !e.subject || !e.project)
|
|
601
754
|
return [];
|
|
602
|
-
return [
|
|
755
|
+
return [
|
|
756
|
+
{
|
|
757
|
+
at: e.at,
|
|
758
|
+
item: e.subject.toUpperCase(),
|
|
759
|
+
kind: data.kind ?? null,
|
|
760
|
+
phase: data.phase,
|
|
761
|
+
project: e.project,
|
|
762
|
+
tier: data.tier ?? null,
|
|
763
|
+
},
|
|
764
|
+
];
|
|
603
765
|
}
|
|
604
766
|
function unique(list) {
|
|
605
767
|
return [...new Set(list)];
|
|
@@ -616,7 +778,9 @@ function waitStats(waits, until) {
|
|
|
616
778
|
return true;
|
|
617
779
|
});
|
|
618
780
|
// A closed wait's resolution says nothing about what it waited on: its condition does.
|
|
619
|
-
return [
|
|
781
|
+
return [
|
|
782
|
+
...groupBy(kept, (w) => waitKind(w.condition, w.resolution !== null && CLOSED_RESOLUTIONS.has(w.resolution) ? null : w.resolution)),
|
|
783
|
+
]
|
|
620
784
|
.map(([kind, list]) => {
|
|
621
785
|
// A wait resolved (or closed) after the window was still open at its end, and counts up to it.
|
|
622
786
|
const endOf = (w) => (w.resolvedAt !== null && w.resolvedAt < until ? w.resolvedAt : until);
|