@nanobpm/nano-workforce 0.106.1 → 0.106.3
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 +14 -0
- package/app/mergesPerDay.test.ts +94 -10
- package/app/mergesPerDay.ts +84 -18
- package/package.json +1 -1
- package/resources/processes/retro.bpmn +30 -26
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.106.3](https://github.com/nanobpm/nano-workforce/compare/v0.106.2...v0.106.3) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **models:** regenerate stale retro.bpmn DI ([#365](https://github.com/nanobpm/nano-workforce/issues/365)) ([5619ee5](https://github.com/nanobpm/nano-workforce/commit/5619ee5805fb788ae5deebeb75bb0aa11f9c478d)), closes [355/#356](https://github.com/nanobpm/nano-workforce/issues/356)
|
|
7
|
+
|
|
8
|
+
## [0.106.2](https://github.com/nanobpm/nano-workforce/compare/v0.106.1...v0.106.2) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **velocity:** bucket burn-up by local timezone, not UTC ([#361](https://github.com/nanobpm/nano-workforce/issues/361)) ([#362](https://github.com/nanobpm/nano-workforce/issues/362)) ([84d842a](https://github.com/nanobpm/nano-workforce/commit/84d842ad443fba8a0c399bdd5f4fca466da4a21c))
|
|
14
|
+
|
|
1
15
|
## [0.106.1](https://github.com/nanobpm/nano-workforce/compare/v0.106.0...v0.106.1) (2026-08-20)
|
|
2
16
|
|
|
3
17
|
|
package/app/mergesPerDay.test.ts
CHANGED
|
@@ -12,6 +12,13 @@ import { assert, assertEquals } from "#test-assert";
|
|
|
12
12
|
import type { DataLayer } from "@nanobpm/urban";
|
|
13
13
|
import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./mergesPerDay.ts";
|
|
14
14
|
|
|
15
|
+
// The bucketing is now LOCAL-calendar-day (issue #361: use the viewer's timezone, not UTC). The
|
|
16
|
+
// derivation buckets in an explicit IANA `timeZone` argument (via `Intl.DateTimeFormat`), so these
|
|
17
|
+
// tests pass the zone directly rather than mutating the process-global `process.env.TZ` — which
|
|
18
|
+
// `node --test` runs concurrently across files, so an in-process `TZ` flip could leak into and
|
|
19
|
+
// reorder unrelated date-handling tests. The UTC-based assertions pass `"UTC"`; the
|
|
20
|
+
// timezone-specific ones pass the zone they exercise.
|
|
21
|
+
|
|
15
22
|
// A tiny in-memory record gateway (all/find/insert/update/delete), mirroring the fake-app style used
|
|
16
23
|
// across the app tests (see app/delivery.test.ts), enough to exercise the `pollMergesPerDay`
|
|
17
24
|
// projection.
|
|
@@ -62,7 +69,7 @@ test("counts DISTINCT merged PRs per calendar day", () => {
|
|
|
62
69
|
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
63
70
|
merged("o/r#2", "2026-01-01T18:30:00Z"),
|
|
64
71
|
merged("o/r#3", "2026-01-02T10:00:00Z"),
|
|
65
|
-
]);
|
|
72
|
+
], "UTC");
|
|
66
73
|
assertEquals(days.map((d) => [d.day, d.merged]), [
|
|
67
74
|
["2026-01-01", 2],
|
|
68
75
|
["2026-01-02", 1],
|
|
@@ -74,7 +81,7 @@ test("dedupes duplicate merged rows for the same PR on the same day (COUNT DISTI
|
|
|
74
81
|
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
75
82
|
merged("o/r#1", "2026-01-01T09:00:05Z"), // retry / already-merged short-circuit
|
|
76
83
|
merged("o/r#1", "2026-01-01T23:59:00Z"),
|
|
77
|
-
]);
|
|
84
|
+
], "UTC");
|
|
78
85
|
assertEquals(days.length, 1);
|
|
79
86
|
assertEquals(days[0].merged, 1);
|
|
80
87
|
});
|
|
@@ -84,7 +91,7 @@ test("the same PR merged on two different days counts once per day", () => {
|
|
|
84
91
|
const days = deriveMergesPerDay([
|
|
85
92
|
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
86
93
|
merged("o/r#1", "2026-01-02T09:00:00Z"),
|
|
87
|
-
]);
|
|
94
|
+
], "UTC");
|
|
88
95
|
assertEquals(days.map((d) => [d.day, d.merged]), [
|
|
89
96
|
["2026-01-01", 1],
|
|
90
97
|
["2026-01-02", 1],
|
|
@@ -96,7 +103,7 @@ test("ignores queued and blocked attempts", () => {
|
|
|
96
103
|
merged("o/r#1", "2026-01-01T09:00:00Z"),
|
|
97
104
|
{ pr_key: "o/r#2", outcome: "queued", at: "2026-01-01T09:10:00Z" },
|
|
98
105
|
{ pr_key: "o/r#3", outcome: "blocked", at: "2026-01-01T09:20:00Z" },
|
|
99
|
-
]);
|
|
106
|
+
], "UTC");
|
|
100
107
|
assertEquals(days.length, 1);
|
|
101
108
|
assertEquals(days[0].merged, 1);
|
|
102
109
|
});
|
|
@@ -107,7 +114,7 @@ test("orders days ascending and carries a running burn-up cumulative", () => {
|
|
|
107
114
|
merged("o/r#1", "2026-01-01T10:00:00Z"),
|
|
108
115
|
merged("o/r#2", "2026-01-01T11:00:00Z"),
|
|
109
116
|
merged("o/r#4", "2026-01-02T10:00:00Z"),
|
|
110
|
-
]);
|
|
117
|
+
], "UTC");
|
|
111
118
|
assertEquals(days.map((d) => d.day), ["2026-01-01", "2026-01-02", "2026-01-03"]);
|
|
112
119
|
assertEquals(days.map((d) => d.merged), [2, 1, 1]);
|
|
113
120
|
assertEquals(days.map((d) => d.cumulative), [2, 3, 4]);
|
|
@@ -122,7 +129,7 @@ test("bar scales against the busiest day: full for the max, non-empty for a lone
|
|
|
122
129
|
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
123
130
|
// day B: 1 merge → short but visible bar
|
|
124
131
|
merged("o/r#5", "2026-01-02T01:00:00Z"),
|
|
125
|
-
]);
|
|
132
|
+
], "UTC");
|
|
126
133
|
const [a, b] = days;
|
|
127
134
|
assert(a.bar.length > b.bar.length, "the busier day must draw a longer bar");
|
|
128
135
|
assert(b.bar.length >= 1, "a day with any merge must draw at least one glyph");
|
|
@@ -133,6 +140,83 @@ test("empty audit yields no days", () => {
|
|
|
133
140
|
assertEquals(deriveMergesPerDay([]), []);
|
|
134
141
|
});
|
|
135
142
|
|
|
143
|
+
test("buckets by the viewer's LOCAL calendar day, not UTC (issue #361)", () => {
|
|
144
|
+
// 02:00Z on Jan 1 is still Dec 31 in a west-of-UTC zone (America/New_York, UTC-5).
|
|
145
|
+
{
|
|
146
|
+
const days = deriveMergesPerDay([merged("o/r#1", "2026-01-01T02:00:00Z")], "America/New_York");
|
|
147
|
+
assertEquals(
|
|
148
|
+
days.map((d) => d.day),
|
|
149
|
+
["2025-12-31"],
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
// 23:00Z on Jan 1 is already Jan 2 in an east-of-UTC zone (Pacific/Kiritimati, UTC+14).
|
|
153
|
+
{
|
|
154
|
+
const days = deriveMergesPerDay([merged("o/r#1", "2026-01-01T23:00:00Z")], "Pacific/Kiritimati");
|
|
155
|
+
assertEquals(
|
|
156
|
+
days.map((d) => d.day),
|
|
157
|
+
["2026-01-02"],
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("two merges either side of local midnight land on the same local day (issue #361)", () => {
|
|
163
|
+
// In UTC these are two different UTC days; in America/New_York (UTC-5) both are Jan 1 evening,
|
|
164
|
+
// so a local-time bucketing counts them together on 2026-01-01.
|
|
165
|
+
{
|
|
166
|
+
const days = deriveMergesPerDay(
|
|
167
|
+
[
|
|
168
|
+
merged("o/r#1", "2026-01-01T18:00:00Z"), // 13:00 local, Jan 1
|
|
169
|
+
merged("o/r#2", "2026-01-02T04:00:00Z"), // 23:00 local, Jan 1
|
|
170
|
+
],
|
|
171
|
+
"America/New_York",
|
|
172
|
+
);
|
|
173
|
+
assertEquals(
|
|
174
|
+
days.map((d) => [d.day, d.merged]),
|
|
175
|
+
[["2026-01-01", 2]],
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("non-ISO / malformed `at` still groups deterministically without throwing", () => {
|
|
181
|
+
const days = deriveMergesPerDay([
|
|
182
|
+
{ pr_key: "o/r#1", outcome: "merged", at: "not-a-timestamp" },
|
|
183
|
+
{ pr_key: "o/r#2", outcome: "merged", at: "not-a-timestamp" },
|
|
184
|
+
], "UTC");
|
|
185
|
+
assertEquals(days.length, 1);
|
|
186
|
+
assertEquals(days[0].day, "not-a-timestamp");
|
|
187
|
+
assertEquals(days[0].merged, 2);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("ambiguous partially-formed `at` (date-only / offset-less) buckets on the trimmed string, not a runtime-dependent day", () => {
|
|
191
|
+
// `new Date("2026-01-01")` parses as UTC midnight while `new Date("2026-01-01T12:00:00")` parses in
|
|
192
|
+
// the host's local zone — bucketing either would be runtime/timezone-dependent, the exact drift this
|
|
193
|
+
// read model exists to avoid. Neither carries an explicit `Z`/offset, so both must fall back to the
|
|
194
|
+
// trimmed string and group deterministically regardless of the viewer's `timeZone`.
|
|
195
|
+
const rows: MergeAuditRow[] = [
|
|
196
|
+
{ pr_key: "o/r#1", outcome: "merged", at: "2026-01-01" },
|
|
197
|
+
{ pr_key: "o/r#2", outcome: "merged", at: "2026-01-01T12:00:00" },
|
|
198
|
+
];
|
|
199
|
+
for (const zone of ["UTC", "America/New_York", "Pacific/Kiritimati"]) {
|
|
200
|
+
const days = deriveMergesPerDay(rows, zone);
|
|
201
|
+
assertEquals(days.map((d) => [d.day, d.merged]), [
|
|
202
|
+
["2026-01-01", 1],
|
|
203
|
+
["2026-01-01T12:00:00", 1],
|
|
204
|
+
]);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("an invalid IANA timeZone falls back to the host zone instead of throwing (issue #361)", () => {
|
|
209
|
+
// A bogus zone would make `Intl.DateTimeFormat` throw a `RangeError`; bucketing must stay
|
|
210
|
+
// deterministic and not wedge `deriveMergesPerDay`/`pollMergesPerDay`.
|
|
211
|
+
const days = deriveMergesPerDay(
|
|
212
|
+
[merged("o/r#1", "2026-01-01T12:00:00Z")],
|
|
213
|
+
"Not/AZone",
|
|
214
|
+
);
|
|
215
|
+
assertEquals(days.length, 1);
|
|
216
|
+
assertEquals(days[0].merged, 1);
|
|
217
|
+
assert(/^\d{4}-\d{2}-\d{2}$/.test(days[0].day));
|
|
218
|
+
});
|
|
219
|
+
|
|
136
220
|
test("pollMergesPerDay projects the aggregate onto merges_per_day", async () => {
|
|
137
221
|
const { data, stores } = memData();
|
|
138
222
|
stores.merges = [
|
|
@@ -141,7 +225,7 @@ test("pollMergesPerDay projects the aggregate onto merges_per_day", async () =>
|
|
|
141
225
|
{ id: 3, pr_key: "o/r#2", outcome: "merged", at: "2026-01-02T09:00:00Z" },
|
|
142
226
|
{ id: 4, pr_key: "o/r#3", outcome: "queued", at: "2026-01-02T09:10:00Z" }, // ignored
|
|
143
227
|
];
|
|
144
|
-
await pollMergesPerDay(data);
|
|
228
|
+
await pollMergesPerDay(data, "UTC");
|
|
145
229
|
const rows = (stores.merges_per_day ?? []).slice().sort((x, y) => x.day.localeCompare(y.day));
|
|
146
230
|
assertEquals(rows.map((r) => [r.day, r.merged, r.cumulative]), [
|
|
147
231
|
["2026-01-01", 1, 1],
|
|
@@ -153,10 +237,10 @@ test("pollMergesPerDay projects the aggregate onto merges_per_day", async () =>
|
|
|
153
237
|
test("pollMergesPerDay is idempotent — a steady-state re-run writes nothing", async () => {
|
|
154
238
|
const { data, stores, writes } = memData();
|
|
155
239
|
stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
|
|
156
|
-
await pollMergesPerDay(data);
|
|
240
|
+
await pollMergesPerDay(data, "UTC");
|
|
157
241
|
const afterFirst = writes();
|
|
158
242
|
assert(afterFirst > 0, "the first pass must project at least one row");
|
|
159
|
-
await pollMergesPerDay(data);
|
|
243
|
+
await pollMergesPerDay(data, "UTC");
|
|
160
244
|
assertEquals(writes(), afterFirst, "a steady-state re-run must not write");
|
|
161
245
|
});
|
|
162
246
|
|
|
@@ -166,7 +250,7 @@ test("pollMergesPerDay prunes a day that no longer derives from the audit", asyn
|
|
|
166
250
|
{ day: "2025-12-31", merged: 3, cumulative: 3, bar: "███", updated_at: "old" },
|
|
167
251
|
];
|
|
168
252
|
stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
|
|
169
|
-
await pollMergesPerDay(data);
|
|
253
|
+
await pollMergesPerDay(data, "UTC");
|
|
170
254
|
const days = (stores.merges_per_day ?? []).map((r: any) => r.day);
|
|
171
255
|
assert(!days.includes("2025-12-31"), "a stale day must be pruned");
|
|
172
256
|
assert(days.includes("2026-01-01"), "the derived day must be present");
|
package/app/mergesPerDay.ts
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
// set is not exhaustive), `at` = ISO timestamp. Only `outcome = 'merged'` rows feed this aggregate,
|
|
8
8
|
// so merged-per-day is
|
|
9
9
|
// fully DERIVABLE from that audit trail with NO new write-path bookkeeping (AGENTS.md: "Derivation
|
|
10
|
-
// over duplication"). The
|
|
10
|
+
// over duplication"). The `at` audit value is UTC, but the day is bucketed in the viewer's LOCAL
|
|
11
|
+
// timezone (issue #361) so an operator sees merges on the calendar day they happened locally, not
|
|
12
|
+
// shifted across a UTC midnight. The canonical aggregate is the one in the issue, in local time:
|
|
11
13
|
//
|
|
12
|
-
// SELECT date(at) AS day, COUNT(DISTINCT pr_key) AS merged
|
|
13
|
-
// FROM merges WHERE outcome = 'merged' GROUP BY date(at);
|
|
14
|
+
// SELECT date(at, 'localtime') AS day, COUNT(DISTINCT pr_key) AS merged
|
|
15
|
+
// FROM merges WHERE outcome = 'merged' GROUP BY date(at, 'localtime');
|
|
14
16
|
//
|
|
15
17
|
// Two halves, mirroring the `deriveDelivery`/`pollDelivery` and `deriveLineage`/`pollLineage`
|
|
16
18
|
// convention:
|
|
@@ -37,7 +39,7 @@ export interface MergeAuditRow {
|
|
|
37
39
|
|
|
38
40
|
/** One projected calendar day of merge throughput. */
|
|
39
41
|
export interface MergeDay {
|
|
40
|
-
/**
|
|
42
|
+
/** Local calendar day, ISO `YYYY-MM-DD` (SQLite `date(at, 'localtime')` — issue #361). */
|
|
41
43
|
day: string;
|
|
42
44
|
/** Distinct PRs merged that day (`COUNT(DISTINCT pr_key)`). */
|
|
43
45
|
merged: number;
|
|
@@ -51,12 +53,72 @@ export interface MergeDay {
|
|
|
51
53
|
const BAR_WIDTH = 30;
|
|
52
54
|
const BAR_FULL = "█";
|
|
53
55
|
|
|
54
|
-
/** The calendar day of an ISO timestamp — the
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
56
|
+
/** The **local** calendar day of an ISO timestamp — the viewer's-timezone twin of SQLite
|
|
57
|
+
* `date(at, 'localtime')` (issue #361). The `merges.at` audit value is a UTC ISO string, but the
|
|
58
|
+
* Velocity page is read by an operator in their own timezone, so bucketing on the UTC date split a
|
|
59
|
+
* single local day across two rows (a late-evening merge west of UTC, or an early-morning one east
|
|
60
|
+
* of it, landed on the wrong day). We derive the day in the target `timeZone` via
|
|
61
|
+
* `Intl.DateTimeFormat` — an explicit, side-effect-free zone rather than one mutated through the
|
|
62
|
+
* process-global `process.env.TZ`. When `timeZone` is omitted the formatter uses the host's
|
|
63
|
+
* resolved zone (so a remote deployment can still pin the operator's zone via `TZ`, and a
|
|
64
|
+
* co-located console — the default `npm start` on `localhost` — is already the browser's zone). An
|
|
65
|
+
* invalid/unknown IANA `timeZone` falls back to the host-resolved zone (rather than throwing a
|
|
66
|
+
* `RangeError` that would wedge `deriveMergesPerDay`/`pollMergesPerDay`), so bucketing stays
|
|
67
|
+
* deterministic. Any value that is not an UNAMBIGUOUS ISO instant — one carrying an explicit
|
|
68
|
+
* timezone designator (`Z` or a `±HH:MM`/`±HHMM` offset) — falls back to the whole trimmed string so
|
|
69
|
+
* it still groups deterministically. This deliberately excludes partially-formed values a bare
|
|
70
|
+
* `new Date(s)` would still parse but *ambiguously*: a date-only `"2026-01-01"` is read as UTC
|
|
71
|
+
* midnight while an offset-less `"2026-01-01T12:00:00"` is read in the host's local zone — so
|
|
72
|
+
* bucketing them would be runtime/timezone-dependent, the very drift this read model exists to avoid.
|
|
73
|
+
* Production `merges.at` values are always `new Date().toISOString()` (UTC, `Z`-suffixed), so only a
|
|
74
|
+
* malformed audit row ever takes the fallback. */
|
|
75
|
+
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
76
|
+
|
|
77
|
+
/** An unambiguous ISO-8601 instant: a full `YYYY-MM-DDTHH:MM[:SS[.sss]]` carrying an explicit zone
|
|
78
|
+
* designator (`Z`, or a `±HH:MM`/`±HHMM` offset). Only these parse to a timezone-independent instant;
|
|
79
|
+
* anything else (date-only, offset-less datetime, free text) buckets ambiguously, so `dayOf` treats
|
|
80
|
+
* it as a non-instant and groups on the trimmed string instead. */
|
|
81
|
+
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
82
|
+
|
|
83
|
+
/** A cached `en-CA` day formatter for `timeZone`, falling back to the host-resolved zone when the
|
|
84
|
+
* zone is invalid/unknown (an invalid IANA string makes `Intl.DateTimeFormat` throw a `RangeError`).
|
|
85
|
+
* Keyed so an invalid zone is only probed once. */
|
|
86
|
+
function dayFormatter(timeZone?: string): Intl.DateTimeFormat {
|
|
87
|
+
const key = timeZone ?? "";
|
|
88
|
+
const cached = dayFormatters.get(key);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
91
|
+
year: "numeric",
|
|
92
|
+
month: "2-digit",
|
|
93
|
+
day: "2-digit",
|
|
94
|
+
};
|
|
95
|
+
let fmt: Intl.DateTimeFormat;
|
|
96
|
+
try {
|
|
97
|
+
fmt = new Intl.DateTimeFormat("en-CA", { ...opts, timeZone });
|
|
98
|
+
} catch {
|
|
99
|
+
fmt = new Intl.DateTimeFormat("en-CA", opts);
|
|
100
|
+
}
|
|
101
|
+
dayFormatters.set(key, fmt);
|
|
102
|
+
return fmt;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function dayOf(at: string, timeZone?: string): string {
|
|
58
106
|
const s = String(at).trim();
|
|
59
|
-
|
|
107
|
+
// Only bucket unambiguous ISO instants (explicit `Z`/offset). A partially-formed value a bare
|
|
108
|
+
// `new Date(s)` would still parse — a date-only or offset-less datetime — buckets differently per
|
|
109
|
+
// runtime/timezone, so group it deterministically on the trimmed string instead.
|
|
110
|
+
if (!ISO_INSTANT.test(s)) return s;
|
|
111
|
+
const d = new Date(s);
|
|
112
|
+
if (Number.isNaN(d.getTime())) return s;
|
|
113
|
+
const parts = dayFormatter(timeZone).formatToParts(d);
|
|
114
|
+
const field = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
|
|
115
|
+
const year = field("year");
|
|
116
|
+
const month = field("month");
|
|
117
|
+
const day = field("day");
|
|
118
|
+
// Guard against a formatter that somehow omits a field — never emit a `"--"`-shaped key; fall back
|
|
119
|
+
// to the trimmed string so the row still groups deterministically.
|
|
120
|
+
if (!year || !month || !day) return s;
|
|
121
|
+
return `${year}-${month}-${day}`;
|
|
60
122
|
}
|
|
61
123
|
|
|
62
124
|
/** Render a proportional bar: `merged` glyphs scaled against the busiest day's `max`, min one glyph
|
|
@@ -69,17 +131,20 @@ function barFor(merged: number, max: number): string {
|
|
|
69
131
|
|
|
70
132
|
/** PURE aggregate: merge audit rows → one ordered `MergeDay` per calendar day (ascending).
|
|
71
133
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
134
|
+
* Days are bucketed in `timeZone` (an IANA zone, e.g. `America/New_York`); omit it to use the host's
|
|
135
|
+
* resolved zone — the production default, matching SQLite `date(at, 'localtime')` for the operator's
|
|
136
|
+
* console (issue #361). Only `outcome === "merged"` rows count; `queued`/`blocked` attempts are
|
|
137
|
+
* ignored. Within a day a `pr_key` is counted once (`COUNT(DISTINCT pr_key)`), so duplicate `merged`
|
|
138
|
+
* audit rows — an `already-merged` short-circuit or a retry — do not double-count. `cumulative` is
|
|
139
|
+
* the running total across days (burn-up); `bar` is scaled against the busiest day so the chart is
|
|
140
|
+
* comparable. */
|
|
141
|
+
export function deriveMergesPerDay(rows: readonly MergeAuditRow[], timeZone?: string): MergeDay[] {
|
|
77
142
|
// day -> set of distinct merged pr_keys that day.
|
|
78
143
|
const prKeysByDay = new Map<string, Set<string>>();
|
|
79
144
|
for (const r of rows) {
|
|
80
145
|
if (r.outcome !== "merged") continue;
|
|
81
146
|
if (r.pr_key == null || r.at == null) continue;
|
|
82
|
-
const day = dayOf(r.at);
|
|
147
|
+
const day = dayOf(r.at, timeZone);
|
|
83
148
|
let set = prKeysByDay.get(day);
|
|
84
149
|
if (!set) {
|
|
85
150
|
set = new Set<string>();
|
|
@@ -114,13 +179,14 @@ const mergesAudit = (data: DataLayer) => data.table<MergeAuditRow>("merges", "id
|
|
|
114
179
|
* it onto the `merges_per_day` read table the Velocity page reads. Additive/derived only — never
|
|
115
180
|
* touches `merges`. Upserts a day only when its projection actually changes (so a steady-state pass is
|
|
116
181
|
* a no-op) and prunes any stale day row that no longer derives (defensive — days are append-only in
|
|
117
|
-
* practice, but a purge/rewrite of the audit must not leave a phantom).
|
|
118
|
-
|
|
182
|
+
* practice, but a purge/rewrite of the audit must not leave a phantom). Buckets in `timeZone` (an
|
|
183
|
+
* IANA zone) when given; the production caller omits it to use the host's resolved zone. */
|
|
184
|
+
export async function pollMergesPerDay(data: DataLayer, timeZone?: string): Promise<void> {
|
|
119
185
|
try {
|
|
120
186
|
// Only `outcome === "merged"` rows contribute to the aggregate, so filter at the read rather than
|
|
121
187
|
// scanning queued/blocked rows as the audit grows (deriveMergesPerDay ignores non-merged rows too).
|
|
122
188
|
const audit = await mergesAudit(data).find({ outcome: "merged" });
|
|
123
|
-
const want = deriveMergesPerDay(audit);
|
|
189
|
+
const want = deriveMergesPerDay(audit, timeZone);
|
|
124
190
|
const wantByDay = new Map(want.map((d) => [d.day, d]));
|
|
125
191
|
|
|
126
192
|
const existing = await mergesPerDay(data).all();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.106.
|
|
3
|
+
"version": "0.106.3",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -146,27 +146,27 @@
|
|
|
146
146
|
<dc:Bounds x="616" y="80" width="100" height="80" />
|
|
147
147
|
</bpmndi:BPMNShape>
|
|
148
148
|
<bpmndi:BPMNShape id="BPMNShape_gw-deviations" bpmnElement="gw-deviations" isMarkerVisible="true">
|
|
149
|
-
<dc:Bounds x="
|
|
149
|
+
<dc:Bounds x="816" y="95" width="50" height="50" />
|
|
150
150
|
<bpmndi:BPMNLabel>
|
|
151
|
-
<dc:Bounds x="
|
|
151
|
+
<dc:Bounds x="801" y="76" width="81" height="14" />
|
|
152
152
|
</bpmndi:BPMNLabel>
|
|
153
153
|
</bpmndi:BPMNShape>
|
|
154
154
|
<bpmndi:BPMNShape id="BPMNShape_conformance-escalation" bpmnElement="conformance-escalation">
|
|
155
|
-
<dc:Bounds x="
|
|
155
|
+
<dc:Bounds x="966" y="240" width="100" height="80" />
|
|
156
156
|
</bpmndi:BPMNShape>
|
|
157
157
|
<bpmndi:BPMNShape id="BPMNShape_record-conformance-ack" bpmnElement="record-conformance-ack">
|
|
158
|
-
<dc:Bounds x="
|
|
158
|
+
<dc:Bounds x="1166" y="240" width="100" height="80" />
|
|
159
159
|
</bpmndi:BPMNShape>
|
|
160
160
|
<bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
|
|
161
|
-
<dc:Bounds x="
|
|
161
|
+
<dc:Bounds x="1366" y="80" width="100" height="80" />
|
|
162
162
|
</bpmndi:BPMNShape>
|
|
163
163
|
<bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
|
|
164
|
-
<dc:Bounds x="
|
|
164
|
+
<dc:Bounds x="1566" y="80" width="100" height="80" />
|
|
165
165
|
</bpmndi:BPMNShape>
|
|
166
166
|
<bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
|
|
167
|
-
<dc:Bounds x="
|
|
167
|
+
<dc:Bounds x="1766" y="102" width="36" height="36" />
|
|
168
168
|
<bpmndi:BPMNLabel>
|
|
169
|
-
<dc:Bounds x="
|
|
169
|
+
<dc:Bounds x="1744" y="143" width="80" height="14" />
|
|
170
170
|
</bpmndi:BPMNLabel>
|
|
171
171
|
</bpmndi:BPMNShape>
|
|
172
172
|
<bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
|
|
@@ -183,32 +183,36 @@
|
|
|
183
183
|
</bpmndi:BPMNEdge>
|
|
184
184
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toDeviationsGw" bpmnElement="f_toDeviationsGw">
|
|
185
185
|
<di:waypoint x="716" y="120" />
|
|
186
|
-
<di:waypoint x="
|
|
186
|
+
<di:waypoint x="816" y="120" />
|
|
187
187
|
</bpmndi:BPMNEdge>
|
|
188
188
|
<bpmndi:BPMNEdge id="BPMNEdge_f_noDeviations" bpmnElement="f_noDeviations">
|
|
189
|
-
<di:waypoint x="
|
|
190
|
-
<di:waypoint x="
|
|
189
|
+
<di:waypoint x="866" y="120" />
|
|
190
|
+
<di:waypoint x="1366" y="120" />
|
|
191
|
+
</bpmndi:BPMNEdge>
|
|
192
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
|
|
193
|
+
<di:waypoint x="1466" y="120" />
|
|
194
|
+
<di:waypoint x="1566" y="120" />
|
|
195
|
+
</bpmndi:BPMNEdge>
|
|
196
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
|
|
197
|
+
<di:waypoint x="1666" y="120" />
|
|
198
|
+
<di:waypoint x="1766" y="120" />
|
|
191
199
|
</bpmndi:BPMNEdge>
|
|
192
200
|
<bpmndi:BPMNEdge id="BPMNEdge_f_deviations" bpmnElement="f_deviations">
|
|
193
|
-
<di:waypoint x="
|
|
194
|
-
<di:waypoint x="
|
|
201
|
+
<di:waypoint x="841" y="145" />
|
|
202
|
+
<di:waypoint x="841" y="280" />
|
|
203
|
+
<di:waypoint x="966" y="280" />
|
|
204
|
+
<bpmndi:BPMNLabel>
|
|
205
|
+
<dc:Bounds x="846" y="206" width="74" height="14" />
|
|
206
|
+
</bpmndi:BPMNLabel>
|
|
195
207
|
</bpmndi:BPMNEdge>
|
|
196
208
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toConformanceAck" bpmnElement="f_toConformanceAck">
|
|
197
|
-
<di:waypoint x="
|
|
198
|
-
<di:waypoint x="
|
|
209
|
+
<di:waypoint x="1066" y="280" />
|
|
210
|
+
<di:waypoint x="1166" y="280" />
|
|
199
211
|
</bpmndi:BPMNEdge>
|
|
200
212
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ackToSynthesize" bpmnElement="f_ackToSynthesize">
|
|
201
|
-
<di:waypoint x="
|
|
202
|
-
<di:waypoint x="
|
|
203
|
-
<di:waypoint x="
|
|
204
|
-
</bpmndi:BPMNEdge>
|
|
205
|
-
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
|
|
206
|
-
<di:waypoint x="1156" y="120" />
|
|
207
|
-
<di:waypoint x="1256" y="120" />
|
|
208
|
-
</bpmndi:BPMNEdge>
|
|
209
|
-
<bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
|
|
210
|
-
<di:waypoint x="1356" y="120" />
|
|
211
|
-
<di:waypoint x="1456" y="120" />
|
|
213
|
+
<di:waypoint x="1266" y="280" />
|
|
214
|
+
<di:waypoint x="1416" y="280" />
|
|
215
|
+
<di:waypoint x="1416" y="160" />
|
|
212
216
|
</bpmndi:BPMNEdge>
|
|
213
217
|
</bpmndi:BPMNPlane>
|
|
214
218
|
</bpmndi:BPMNDiagram>
|