@nanobpm/nano-workforce 0.119.0 → 0.120.1
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 +15 -0
- package/app/capabilityNeed.test.ts +4 -2
- package/app/capabilityNeed.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +2 -2
- package/app/deliveryGraphCompiler.ts +63 -17
- package/app/deliveryRunner.test.ts +1 -0
- package/app/deliveryRunner.ts +16 -4
- package/app/feature.test.ts +3 -1
- package/app/feature.ts +9 -0
- package/app/featureReadiness.test.ts +7 -4
- package/app/featureReadiness.ts +8 -3
- package/app/lineage.ts +25 -0
- package/app/mergesPerDayView.test.ts +166 -0
- package/app/migration064.test.ts +176 -0
- package/app/plan.test.ts +1 -1
- package/app/plan.ts +9 -0
- package/app/planFanoutPreflight.test.ts +12 -12
- package/app/planLowering.test.ts +2 -0
- package/app/planLowering.ts +7 -2
- package/app/planWaveSummary.test.ts +4 -2
- package/app/plansReadModel.test.ts +262 -0
- package/app/readiness.test.ts +9 -0
- package/app/readiness.ts +25 -0
- package/app/service.ts +8 -1
- package/biome.json +24 -1
- package/db/migrations/060_plan_wave_rollup.sql +52 -0
- package/db/migrations/061_plan_delivery_rollup.sql +98 -0
- package/db/migrations/062_merges_per_day_view.sql +72 -0
- package/db/migrations/064_lineage_thread_view.sql +75 -0
- package/e2e/delivery-graph.e2e.ts +2 -1
- package/e2e/feature-preflight.e2e.ts +2 -0
- package/e2e/inter-epic-dependency.e2e.ts +7 -1
- package/e2e/plan-fanout-preflight.e2e.ts +2 -0
- package/e2e/readiness-gate.e2e.ts +27 -11
- package/package.json +1 -1
- package/pages/epic-detail.page.json +2 -2
- package/pages/lineage.page.json +1 -1
- package/pages/overview.page.json +1 -1
- package/pages/velocity.page.json +1 -1
- package/resources/processes/feature.bpmn +168 -52
- package/resources/processes/plan-fanout.bpmn +168 -52
- package/resources/processes/readiness-gate.bpmn +194 -84
- package/scripts/pages-contract.test.ts +3 -1
- package/workers/readiness-probe/worker.test.ts +82 -238
- package/workers/readiness-probe/worker.ts +46 -128
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [0.120.1](https://github.com/nanobpm/nano-workforce/compare/v0.120.0...v0.120.1) (2026-08-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **readiness:** make pr.readiness-probe single-shot; engine owns retry cadence ([#428](https://github.com/nanobpm/nano-workforce/issues/428)) ([#429](https://github.com/nanobpm/nano-workforce/issues/429)) ([07db031](https://github.com/nanobpm/nano-workforce/commit/07db031f0d5e3f63ff47ffa51583bdef3012a2ab))
|
|
7
|
+
|
|
8
|
+
# [0.120.0](https://github.com/nanobpm/nano-workforce/compare/v0.119.0...v0.120.0) (2026-08-22)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **lineage:** derive view-expressible lineage_threads columns via SQL VIEW ([#437](https://github.com/nanobpm/nano-workforce/issues/437)) ([f25c824](https://github.com/nanobpm/nano-workforce/commit/f25c824bf9c72c3757a58794065a5c86b208dbe0)), closes [nano-ide#424](https://github.com/nano-ide/issues/424) [nanobpm/nano-workforce#412](https://github.com/nanobpm/nano-workforce/issues/412)
|
|
14
|
+
* **velocity:** back merged-per-day with a SQL VIEW, repoint Velocity page ([#412](https://github.com/nanobpm/nano-workforce/issues/412)) ([#436](https://github.com/nanobpm/nano-workforce/issues/436)) ([5be81e4](https://github.com/nanobpm/nano-workforce/commit/5be81e466f2593622ae837d6e63b00f0287c305d)), closes [nano-ide#424](https://github.com/nano-ide/issues/424) [#411](https://github.com/nanobpm/nano-workforce/issues/411) [#361](https://github.com/nanobpm/nano-workforce/issues/361)
|
|
15
|
+
|
|
1
16
|
# [0.119.0](https://github.com/nanobpm/nano-workforce/compare/v0.118.2...v0.119.0) (2026-08-22)
|
|
2
17
|
|
|
3
18
|
|
|
@@ -106,9 +106,11 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
|
|
|
106
106
|
planKey: "owner/repo#289",
|
|
107
107
|
taskId: "issue-289",
|
|
108
108
|
probeTimeout: "PT12H",
|
|
109
|
+
probePollEvery: "PT15S",
|
|
109
110
|
});
|
|
110
111
|
assertEquals(input.gateKey, "owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban");
|
|
111
112
|
assertEquals(input.probeTimeout, "PT12H");
|
|
113
|
+
assertEquals(input.probePollEvery, "PT15S");
|
|
112
114
|
assertEquals(input.onTimeout, "escalate");
|
|
113
115
|
assertEquals(input.probe.kind, "capability");
|
|
114
116
|
assertEquals(input.probe.target, "github-releases:nanobpm/nano-ide");
|
|
@@ -121,7 +123,7 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
|
|
|
121
123
|
test("capabilityNeedToProbeInput: omits verifyCommand when the need has none", () => {
|
|
122
124
|
const input = capabilityNeedToProbeInput(
|
|
123
125
|
{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" },
|
|
124
|
-
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
|
|
126
|
+
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
|
|
125
127
|
);
|
|
126
128
|
assertEquals(input.probe.match?.verifyCommand, undefined);
|
|
127
129
|
});
|
|
@@ -131,7 +133,7 @@ test("capabilityNeedToProbeInput: throws when the handle names no releases sourc
|
|
|
131
133
|
() =>
|
|
132
134
|
capabilityNeedToProbeInput(
|
|
133
135
|
{ capabilityRef: "#274", package: "@nanobpm/urban" },
|
|
134
|
-
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
|
|
136
|
+
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
|
|
135
137
|
),
|
|
136
138
|
UnresolvableCapabilityRefError,
|
|
137
139
|
"names no owner/repo releases source",
|
package/app/capabilityNeed.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface CapabilityNeed {
|
|
|
36
36
|
export interface ReadinessProbeInput {
|
|
37
37
|
readonly gateKey: string;
|
|
38
38
|
readonly probeTimeout: string;
|
|
39
|
+
readonly probePollEvery: string;
|
|
39
40
|
readonly onTimeout?: OnTimeout;
|
|
40
41
|
readonly probe: ReadinessProbe;
|
|
41
42
|
}
|
|
@@ -152,7 +153,7 @@ export function capabilityTaskBarrierKey(planKey: string, taskId: string): strin
|
|
|
152
153
|
* the handle names no releases source. */
|
|
153
154
|
export function capabilityNeedToProbeInput(
|
|
154
155
|
need: CapabilityNeed,
|
|
155
|
-
opts: { planKey: string; taskId: string; probeTimeout: string },
|
|
156
|
+
opts: { planKey: string; taskId: string; probeTimeout: string; probePollEvery: string },
|
|
156
157
|
): ReadinessProbeInput {
|
|
157
158
|
const repo = capabilityReleasesRepo(need.capabilityRef);
|
|
158
159
|
if (!repo) throw new UnresolvableCapabilityRefError(need.capabilityRef);
|
|
@@ -170,6 +171,7 @@ export function capabilityNeedToProbeInput(
|
|
|
170
171
|
return {
|
|
171
172
|
gateKey: capabilityGateKey(opts.planKey, opts.taskId, need.capabilityRef, need.package),
|
|
172
173
|
probeTimeout: opts.probeTimeout,
|
|
174
|
+
probePollEvery: opts.probePollEvery,
|
|
173
175
|
onTimeout: "escalate",
|
|
174
176
|
probe,
|
|
175
177
|
};
|
|
@@ -85,10 +85,10 @@ test("determinism: the same JSON always yields byte-identical bpmn/diagram/resol
|
|
|
85
85
|
|
|
86
86
|
test("trust bound: every node inlines an embedded subProcess delegating to an allowlisted body — no other activity type", () => {
|
|
87
87
|
const r = compileOk(RELEASE_RUNBOOK);
|
|
88
|
-
// Each of the 4 nodes compiles to an EMBEDDED subProcess (call activities are a no-op on the pinned
|
|
88
|
+
// Each of the 4 nodes compiles to an EMBEDDED subProcess; wait adds one nested retry-loop subProcess (call activities are a no-op on the pinned
|
|
89
89
|
// WASM engine, so delegation is an inlined subProcess sharing the parent scope — never a callActivity).
|
|
90
90
|
assertEquals((r.bpmn.match(/<bpmn:callActivity/g) ?? []).length, 0);
|
|
91
|
-
assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length,
|
|
91
|
+
assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length, 5);
|
|
92
92
|
assert(!r.bpmn.includes("<bpmn:scriptTask"), "no script task is ever emitted");
|
|
93
93
|
// Each node's inner body delegates to an allowlisted engine-native body: a `serviceTask` typed to a
|
|
94
94
|
// worker (agent → its `senior:*` job; wait → `pr.readiness-probe`; connector → `pr.delivery-connector`)
|
|
@@ -604,6 +604,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
604
604
|
inputs.push({ source: cfg("gateKey"), target: "gateKey" });
|
|
605
605
|
inputs.push({ source: cfg("probe"), target: "probe" });
|
|
606
606
|
inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
|
|
607
|
+
inputs.push({ source: cfg("probePollEvery"), target: "probePollEvery" });
|
|
607
608
|
break;
|
|
608
609
|
case "human":
|
|
609
610
|
inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
|
|
@@ -712,33 +713,78 @@ function waitBodyLines(el: string, nodeId: string): string[] {
|
|
|
712
713
|
const esc = escalationTaskElement(el);
|
|
713
714
|
return [
|
|
714
715
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
715
|
-
` <bpmn:
|
|
716
|
+
` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
|
|
717
|
+
" <bpmn:extensionElements>",
|
|
718
|
+
" <zeebe:ioMapping>",
|
|
719
|
+
' <zeebe:output source="=ready" target="ready" />',
|
|
720
|
+
' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
|
|
721
|
+
' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
|
|
722
|
+
' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
|
|
723
|
+
" </zeebe:ioMapping>",
|
|
724
|
+
" </bpmn:extensionElements>",
|
|
725
|
+
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
726
|
+
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
727
|
+
` <bpmn:startEvent id="${el}_loopStart"><bpmn:outgoing>${el}_li0</bpmn:outgoing></bpmn:startEvent>`,
|
|
728
|
+
` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
|
|
729
|
+
" <bpmn:extensionElements>",
|
|
730
|
+
` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
|
|
731
|
+
" <zeebe:properties>",
|
|
732
|
+
' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
|
|
733
|
+
' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
|
|
734
|
+
" </zeebe:properties>",
|
|
735
|
+
" </bpmn:extensionElements>",
|
|
736
|
+
` <bpmn:incoming>${el}_li0</bpmn:incoming>`,
|
|
737
|
+
` <bpmn:incoming>${el}_li4</bpmn:incoming>`,
|
|
738
|
+
` <bpmn:outgoing>${el}_li1</bpmn:outgoing>`,
|
|
739
|
+
" </bpmn:serviceTask>",
|
|
740
|
+
` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_li3">`,
|
|
741
|
+
` <bpmn:incoming>${el}_li1</bpmn:incoming>`,
|
|
742
|
+
` <bpmn:outgoing>${el}_li2</bpmn:outgoing>`,
|
|
743
|
+
` <bpmn:outgoing>${el}_li3</bpmn:outgoing>`,
|
|
744
|
+
" </bpmn:exclusiveGateway>",
|
|
745
|
+
` <bpmn:intermediateCatchEvent id="${el}_waitPoll" name="Wait poll interval">`,
|
|
746
|
+
` <bpmn:incoming>${el}_li3</bpmn:incoming>`,
|
|
747
|
+
` <bpmn:outgoing>${el}_li4</bpmn:outgoing>`,
|
|
748
|
+
` <bpmn:timerEventDefinition id="${el}_pollTed"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probePollEvery</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
749
|
+
" </bpmn:intermediateCatchEvent>",
|
|
750
|
+
` <bpmn:endEvent id="${el}_loopEnd"><bpmn:incoming>${el}_li2</bpmn:incoming></bpmn:endEvent>`,
|
|
751
|
+
flow(`${el}_li0`, `${el}_loopStart`, `${el}_task`),
|
|
752
|
+
flow(`${el}_li1`, `${el}_task`, `${el}_gw`),
|
|
753
|
+
` <bpmn:sequenceFlow id="${el}_li2" name="ready" sourceRef="${el}_gw" targetRef="${el}_loopEnd"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
754
|
+
` <bpmn:sequenceFlow id="${el}_li3" name="not ready" sourceRef="${el}_gw" targetRef="${el}_waitPoll" />`,
|
|
755
|
+
flow(`${el}_li4`, `${el}_waitPoll`, `${el}_task`),
|
|
756
|
+
" </bpmn:subProcess>",
|
|
757
|
+
` <bpmn:boundaryEvent id="${el}_be" name="Gate timed out" attachedToRef="${el}_probeLoop">`,
|
|
758
|
+
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
759
|
+
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
760
|
+
" </bpmn:boundaryEvent>",
|
|
761
|
+
` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${escapeXml(nodeId)}">`,
|
|
716
762
|
" <bpmn:extensionElements>",
|
|
717
763
|
` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
|
|
718
764
|
" <zeebe:properties>",
|
|
719
765
|
' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
|
|
720
766
|
' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
|
|
721
767
|
" </zeebe:properties>",
|
|
768
|
+
" <zeebe:ioMapping>",
|
|
769
|
+
' <zeebe:input source="=true" target="lastAttempt" />',
|
|
770
|
+
" </zeebe:ioMapping>",
|
|
722
771
|
" </bpmn:extensionElements>",
|
|
723
|
-
` <bpmn:incoming>${el}
|
|
724
|
-
` <bpmn:outgoing>${el}
|
|
772
|
+
` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
|
|
773
|
+
` <bpmn:outgoing>${el}_i6</bpmn:outgoing>`,
|
|
725
774
|
" </bpmn:serviceTask>",
|
|
726
|
-
` <bpmn:
|
|
727
|
-
` <bpmn:
|
|
728
|
-
` <bpmn:
|
|
729
|
-
" </bpmn:boundaryEvent>",
|
|
730
|
-
` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_i4">`,
|
|
731
|
-
` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
|
|
732
|
-
` <bpmn:outgoing>${el}_i3</bpmn:outgoing>`,
|
|
775
|
+
` <bpmn:exclusiveGateway id="${el}_lastGw" name="ready after boundary?" default="${el}_i4">`,
|
|
776
|
+
` <bpmn:incoming>${el}_i6</bpmn:incoming>`,
|
|
777
|
+
` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
|
|
733
778
|
` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
|
|
734
779
|
" </bpmn:exclusiveGateway>",
|
|
735
|
-
...escalationTaskLines(esc, nodeId, [`${el}
|
|
736
|
-
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}
|
|
737
|
-
flow(`${el}_i0`, `${el}_start`, `${el}
|
|
738
|
-
flow(`${el}_i1`, `${el}
|
|
739
|
-
flow(`${el}_i2`, `${el}_be`,
|
|
740
|
-
|
|
741
|
-
` <bpmn:sequenceFlow id="${el}
|
|
780
|
+
...escalationTaskLines(esc, nodeId, [`${el}_i4`], `${el}_i5`),
|
|
781
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
|
|
782
|
+
flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
|
|
783
|
+
flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
|
|
784
|
+
flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
|
|
785
|
+
flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
|
|
786
|
+
` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
787
|
+
` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${esc}" />`,
|
|
742
788
|
flow(`${el}_i5`, esc, `${el}_end`),
|
|
743
789
|
];
|
|
744
790
|
}
|
|
@@ -64,6 +64,7 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
|
|
|
64
64
|
const wait = byField((v) => "gateKey" in v);
|
|
65
65
|
assertEquals(wait?.gateKey, "run-7:n3");
|
|
66
66
|
assertEquals(wait?.probeTimeout, "PT20M");
|
|
67
|
+
assertEquals(wait?.probePollEvery, "PT15S");
|
|
67
68
|
assert(wait?.probe && typeof wait.probe === "object", "the wait node carries its ReadinessProbe descriptor");
|
|
68
69
|
|
|
69
70
|
const human = byField((v) => "escalationSlaTimeout" in v);
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
17
17
|
import type { EngineClient } from "@nanobpm/urban";
|
|
18
18
|
import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
19
19
|
import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
20
|
+
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
|
|
20
21
|
|
|
21
22
|
/** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
|
|
22
23
|
* content-addressed deploy id (`delivery-graph-<digest>`) AND the S5 dispatch door's approval token /
|
|
@@ -35,6 +36,8 @@ export interface DeliveryRunTimeouts {
|
|
|
35
36
|
probeTimeout?: string;
|
|
36
37
|
/** `human` node SLA before it records an `escalated` outcome and settles. */
|
|
37
38
|
escalationSlaTimeout?: string;
|
|
39
|
+
/** `wait` gate retry cadence owned by the engine. */
|
|
40
|
+
probePollEvery?: string;
|
|
38
41
|
/** Optional explicit assignee for `human` nodes + escalation tasks (else candidate-group routed). */
|
|
39
42
|
escalationAssignee?: string | null;
|
|
40
43
|
}
|
|
@@ -51,6 +54,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
51
54
|
const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
|
|
52
55
|
nodeTimeout: "PT30M",
|
|
53
56
|
probeTimeout: "PT30M",
|
|
57
|
+
probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
|
|
54
58
|
escalationSlaTimeout: "P1D",
|
|
55
59
|
};
|
|
56
60
|
|
|
@@ -59,7 +63,7 @@ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
|
|
|
59
63
|
* silently seeds `null` into a node body), so both derive from the same node kinds. */
|
|
60
64
|
type NodeInput =
|
|
61
65
|
| { jobType: string; appendPrompt: string; timeout: string }
|
|
62
|
-
| { gateKey: string; probe: unknown; probeTimeout: string }
|
|
66
|
+
| { gateKey: string; probe: unknown; probeTimeout: string; probePollEvery: string }
|
|
63
67
|
| { escalationSlaTimeout: string; escalationAssignee: string | null }
|
|
64
68
|
| { target: string; dedupeKey: string | null; payload: Record<string, unknown> | null; timeout: string };
|
|
65
69
|
|
|
@@ -105,6 +109,7 @@ export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunO
|
|
|
105
109
|
const timeouts = {
|
|
106
110
|
nodeTimeout: options.nodeTimeout ?? DEFAULTS.nodeTimeout,
|
|
107
111
|
probeTimeout: options.probeTimeout ?? DEFAULTS.probeTimeout,
|
|
112
|
+
probePollEvery: options.probePollEvery ?? DEFAULTS.probePollEvery,
|
|
108
113
|
escalationSlaTimeout: options.escalationSlaTimeout ?? DEFAULTS.escalationSlaTimeout,
|
|
109
114
|
escalationAssignee: options.escalationAssignee ?? null,
|
|
110
115
|
};
|
|
@@ -155,13 +160,20 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
|
|
|
155
160
|
* subProcess ioMapping pulls. Total over the closed kind set. */
|
|
156
161
|
function buildNodeInput(
|
|
157
162
|
node: DeliveryNode,
|
|
158
|
-
ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; escalationSlaTimeout: string; escalationAssignee: string | null },
|
|
163
|
+
ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null },
|
|
159
164
|
): NodeInput {
|
|
160
165
|
switch (node.kind) {
|
|
161
166
|
case "agent":
|
|
162
167
|
return { jobType: node.agent.jobType, appendPrompt: node.agent.prompt ?? "", timeout: ctx.nodeTimeout };
|
|
163
|
-
case "wait":
|
|
164
|
-
|
|
168
|
+
case "wait": {
|
|
169
|
+
const probe = parseProbe(node.wait);
|
|
170
|
+
return {
|
|
171
|
+
gateKey: `${ctx.runKey}:${ctx.element}`,
|
|
172
|
+
probe: node.wait,
|
|
173
|
+
probeTimeout: ctx.probeTimeout,
|
|
174
|
+
probePollEvery: probe.poll?.everyMs ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
165
177
|
case "human":
|
|
166
178
|
return { escalationSlaTimeout: ctx.escalationSlaTimeout, escalationAssignee: ctx.escalationAssignee };
|
|
167
179
|
case "connector":
|
package/app/feature.test.ts
CHANGED
|
@@ -320,6 +320,7 @@ test("startFeature: no readiness ⇒ readinessProbes/probeTimeout/gateKey seeded
|
|
|
320
320
|
const v = captured.variables;
|
|
321
321
|
assertEquals(v.readinessProbes, null);
|
|
322
322
|
assertEquals(v.probeTimeout, null);
|
|
323
|
+
assertEquals(v.probePollEvery, null);
|
|
323
324
|
assertEquals(v.gateKey, null);
|
|
324
325
|
assertEquals(v.resolvedArtifacts, null);
|
|
325
326
|
});
|
|
@@ -348,11 +349,12 @@ test("startFeature: readiness probes seed the gate variables + a non-blank corre
|
|
|
348
349
|
false,
|
|
349
350
|
false,
|
|
350
351
|
null,
|
|
351
|
-
{ probes, probeTimeout: "PT30M" },
|
|
352
|
+
{ probes, probeTimeout: "PT30M", probePollEvery: "PT15S" },
|
|
352
353
|
);
|
|
353
354
|
const v = captured.variables;
|
|
354
355
|
assertEquals(v.readinessProbes, probes);
|
|
355
356
|
assertEquals(v.probeTimeout, "PT30M");
|
|
357
|
+
assertEquals(v.probePollEvery, "PT15S");
|
|
356
358
|
// The preflight probe worker requires a non-blank gateKey to publish readiness-ready on.
|
|
357
359
|
assertEquals(v.gateKey, "feature-readiness:owner/repo#42");
|
|
358
360
|
assertEquals(v.resolvedArtifacts, null);
|
package/app/feature.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
|
29
29
|
export interface FeatureReadinessOptions {
|
|
30
30
|
readonly probes?: ReadinessProbe[];
|
|
31
31
|
readonly probeTimeout?: string | null;
|
|
32
|
+
readonly probePollEvery?: string | null;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** The BPMN process this module drives (resources/processes/feature.bpmn). */
|
|
@@ -367,6 +368,13 @@ export async function startFeature(
|
|
|
367
368
|
"a non-blank bound. Derive it via parseFeatureReadiness before starting a gated feature.",
|
|
368
369
|
);
|
|
369
370
|
}
|
|
371
|
+
if (readinessProbes && (readiness.probePollEvery ?? "").trim() === "") {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`startFeature(${parsed.planKey}): ${readinessProbes.length} readiness probe(s) seeded without a ` +
|
|
374
|
+
"probePollEvery — the preflight retry timers (=probePollEvery) require a non-blank cadence. " +
|
|
375
|
+
"Derive it via parseFeatureReadiness before starting a gated feature.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
370
378
|
// Operator free-text steering for the implementation agent (issue #172 follow-on): blank/absent →
|
|
371
379
|
// null so the implement task's `appendPrompt` FEEL (`customInstructions = null`) skips the block
|
|
372
380
|
// rather than appending an empty "Operator custom instructions" heading.
|
|
@@ -487,6 +495,7 @@ export async function startFeature(
|
|
|
487
495
|
// gate-less run still resolves the variable in that FEEL instead of raising an incident.
|
|
488
496
|
readinessProbes,
|
|
489
497
|
probeTimeout: readinessProbes ? (readiness.probeTimeout ?? null) : null,
|
|
498
|
+
probePollEvery: readinessProbes ? (readiness.probePollEvery ?? null) : null,
|
|
490
499
|
gateKey: readinessProbes ? `feature-readiness:${parsed.planKey}` : null,
|
|
491
500
|
resolvedArtifacts: null,
|
|
492
501
|
},
|
|
@@ -9,12 +9,12 @@ import { test } from "node:test";
|
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
10
|
import { parseFeatureReadiness } from "./featureReadiness.ts";
|
|
11
11
|
|
|
12
|
-
const ENV = { NANO_READINESS_POLL_TIMEOUT: "PT30M" } as Record<string, string | undefined>;
|
|
12
|
+
const ENV = { NANO_READINESS_POLL_TIMEOUT: "PT30M", NANO_READINESS_POLL_EVERY_MS: "15000" } as Record<string, string | undefined>;
|
|
13
13
|
|
|
14
14
|
test("parseFeatureReadiness: no intake ⇒ empty probes, null bound (gate skipped)", () => {
|
|
15
|
-
assertEquals(parseFeatureReadiness(undefined, ENV), { probes: [], probeTimeout: null });
|
|
16
|
-
assertEquals(parseFeatureReadiness({}, ENV), { probes: [], probeTimeout: null });
|
|
17
|
-
assertEquals(parseFeatureReadiness({ readiness: [], blockedOn: [] }, ENV), { probes: [], probeTimeout: null });
|
|
15
|
+
assertEquals(parseFeatureReadiness(undefined, ENV), { probes: [], probeTimeout: null, probePollEvery: null });
|
|
16
|
+
assertEquals(parseFeatureReadiness({}, ENV), { probes: [], probeTimeout: null, probePollEvery: null });
|
|
17
|
+
assertEquals(parseFeatureReadiness({ readiness: [], blockedOn: [] }, ENV), { probes: [], probeTimeout: null, probePollEvery: null });
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
test("parseFeatureReadiness: blockedOn + consumerPackage ⇒ capability probes with derived bound", () => {
|
|
@@ -32,6 +32,7 @@ test("parseFeatureReadiness: blockedOn + consumerPackage ⇒ capability probes w
|
|
|
32
32
|
assertEquals(out.probes[1].match?.capabilityRef, "nanobpm/nano-bpm#808");
|
|
33
33
|
// Every derived probe shares the env default, so the bound is that default.
|
|
34
34
|
assertEquals(out.probeTimeout, "PT30M");
|
|
35
|
+
assertEquals(out.probePollEvery, "PT15S");
|
|
35
36
|
});
|
|
36
37
|
|
|
37
38
|
test("parseFeatureReadiness: blockedOn without consumerPackage ⇒ command state probes (merged-is-enough)", () => {
|
|
@@ -43,6 +44,7 @@ test("parseFeatureReadiness: blockedOn without consumerPackage ⇒ command state
|
|
|
43
44
|
onTimeout: "escalate",
|
|
44
45
|
});
|
|
45
46
|
assertEquals(out.probeTimeout, "PT30M");
|
|
47
|
+
assertEquals(out.probePollEvery, "PT15S");
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
test("parseFeatureReadiness: full readiness descriptors round-trip through parseProbe", () => {
|
|
@@ -82,6 +84,7 @@ test("parseFeatureReadiness: a longer per-probe budget wins the derived bound",
|
|
|
82
84
|
ENV,
|
|
83
85
|
);
|
|
84
86
|
assertEquals(out.probeTimeout, "PT3600S");
|
|
87
|
+
assertEquals(out.probePollEvery, "PT15S");
|
|
85
88
|
});
|
|
86
89
|
|
|
87
90
|
test("parseFeatureReadiness: a bare repo#N handle is rejected (cannot name a provenance repo)", () => {
|
package/app/featureReadiness.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
DEFAULT_READINESS_TIMEOUT,
|
|
27
27
|
parseProbe,
|
|
28
28
|
type ReadinessProbe,
|
|
29
|
+
readinessPollEvery,
|
|
29
30
|
readinessTimeout,
|
|
30
31
|
} from "./readiness.ts";
|
|
31
32
|
import { isoDurationToMs } from "./reviewWait.ts";
|
|
@@ -47,6 +48,7 @@ export interface FeatureReadinessInput {
|
|
|
47
48
|
export interface FeatureReadiness {
|
|
48
49
|
readonly probes: ReadinessProbe[];
|
|
49
50
|
readonly probeTimeout: string | null;
|
|
51
|
+
readonly probePollEvery: string | null;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
function isNonEmptyString(v: unknown): v is string {
|
|
@@ -104,7 +106,7 @@ function desugarHandle(handle: string, consumerPackage: string | null): Readines
|
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/** Parse + desugar a feature's optional intake readiness into the gate's `readinessProbes` +
|
|
107
|
-
* `probeTimeout`. Accepts EITHER the full `readiness` descriptor list OR the `blockedOn` shorthand
|
|
109
|
+
* `probeTimeout`/`probePollEvery`. Accepts EITHER the full `readiness` descriptor list OR the `blockedOn` shorthand
|
|
108
110
|
* (or both — they concatenate). Returns an empty probe set (gate skipped) when neither is present.
|
|
109
111
|
*
|
|
110
112
|
* Throws a descriptive error on a malformed descriptor (via {@link parseProbe}), a `blockedOn` entry
|
|
@@ -139,7 +141,7 @@ export function parseFeatureReadiness(
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
if (probes.length === 0) return { probes: [], probeTimeout: null };
|
|
144
|
+
if (probes.length === 0) return { probes: [], probeTimeout: null, probePollEvery: null };
|
|
143
145
|
|
|
144
146
|
// One bound governs the whole preflight's escalation timers — the LONGEST of the probes' derived
|
|
145
147
|
// timeouts (via the canonical `readinessTimeout`), so no probe is cut short. Mirrors the epic
|
|
@@ -147,5 +149,8 @@ export function parseFeatureReadiness(
|
|
|
147
149
|
const probeTimeout = probes
|
|
148
150
|
.map((p) => readinessTimeout(p, env))
|
|
149
151
|
.reduce((a, b) => (isoDurationToMs(b, DEFAULT_READINESS_TIMEOUT) > isoDurationToMs(a, DEFAULT_READINESS_TIMEOUT) ? b : a));
|
|
150
|
-
|
|
152
|
+
const probePollEvery = probes
|
|
153
|
+
.map((p) => readinessPollEvery(p, env))
|
|
154
|
+
.reduce((a, b) => (isoDurationToMs(b, DEFAULT_READINESS_TIMEOUT) < isoDurationToMs(a, DEFAULT_READINESS_TIMEOUT) ? b : a));
|
|
155
|
+
return { probes, probeTimeout, probePollEvery };
|
|
151
156
|
}
|
package/app/lineage.ts
CHANGED
|
@@ -330,6 +330,31 @@ export interface LineageThreadRow {
|
|
|
330
330
|
const lineageThreads = (data: DataLayer) =>
|
|
331
331
|
data.table<LineageThreadRow>("lineage_threads", "root_request_key");
|
|
332
332
|
|
|
333
|
+
/** Read-only handle on the `lineage_thread_view` VIEW (migration 064) the Lineage page now binds.
|
|
334
|
+
* Same shape as `LineageThreadRow`: the view PASSES THROUGH the procedural frontier columns from
|
|
335
|
+
* `lineage_threads` (`stage`/`stage_label`/`process_key`/`pr_keys`/`pr_count`/`active`/timestamps)
|
|
336
|
+
* and DERIVES the view-expressible identity columns (`kind`, `issue_url`, and an epic/feature
|
|
337
|
+
* thread's `title`) from the `plans`/`feature_runs` origin joins — the single source of truth,
|
|
338
|
+
* eliminating those columns' drift surface (epic #412). A self-rooted PR's `title` falls back to
|
|
339
|
+
* the poller-written value, since it is a procedural representative-PR pick. */
|
|
340
|
+
const lineageThreadView = (data: DataLayer) =>
|
|
341
|
+
data.table<LineageThreadRow>("lineage_thread_view", "root_request_key");
|
|
342
|
+
|
|
343
|
+
/** All lineage threads off the same `lineage_thread_view` VIEW the Lineage page binds, in this
|
|
344
|
+
* module's own active-frontier-first, then-by-key stable order (matching {@link listLineage}) —
|
|
345
|
+
* NOT the page datasource's `orderBy: updated_at desc` + tab-specific `active` filter, which the
|
|
346
|
+
* page applies on top of this table. Additive: this reads the derived view rather than recomputing
|
|
347
|
+
* via {@link collectThreads}, so it reflects the identity columns' single source of truth (origin
|
|
348
|
+
* joins) while the frontier columns come through from the still-poller-written `lineage_threads`. */
|
|
349
|
+
export async function listLineageView(data: DataLayer): Promise<LineageThreadRow[]> {
|
|
350
|
+
const rows = await lineageThreadView(data).all();
|
|
351
|
+
return rows.sort(
|
|
352
|
+
(a, b) =>
|
|
353
|
+
Number(b.active) - Number(a.active) ||
|
|
354
|
+
a.root_request_key.localeCompare(b.root_request_key),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
333
358
|
function toLineagePr(row: PrRow): LineagePr {
|
|
334
359
|
return {
|
|
335
360
|
prKey: row.pr_key,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Coverage for the merged-per-day VIEW that retires the denormalised `merges_per_day` table
|
|
2
|
+
// (epic #412; the read model originally shipped in issue #344 / 051_merges_per_day.sql).
|
|
3
|
+
//
|
|
4
|
+
// The whole point of #412 is that this aggregate — merged-per-day throughput + burn-up + a
|
|
5
|
+
// pre-formatted proportional bar — is now a derived SQL VIEW (enabled by nano-ide#424) instead of a
|
|
6
|
+
// worker-written flat table, so it is a single source of truth with no drift. This test therefore
|
|
7
|
+
// exercises the REAL SQLite view (062_merges_per_day_view.sql applied to an in-memory DB, mirroring
|
|
8
|
+
// migration053.test.ts / planWaveSummary.test.ts) and pins that it reproduces the previous
|
|
9
|
+
// projection's EXACT values: the DISTINCT-per-day count, the ascending burn-up `cumulative`, and the
|
|
10
|
+
// byte-for-byte pre-formatted `bar` string the Velocity `prose` renderer draws.
|
|
11
|
+
//
|
|
12
|
+
// `deriveMergesPerDay` (app/mergesPerDay.ts) is the pure function the retired `pollMergesPerDay`
|
|
13
|
+
// write-path used, so it is the authoritative oracle for "what the table held". We assert the view
|
|
14
|
+
// equals it over sample `merges` rows. Bucketing is local-calendar-day (issue #361 — the view uses
|
|
15
|
+
// `date(at, 'localtime')`, which resolves against the host zone). To keep the SQLite `localtime`
|
|
16
|
+
// bucketing and the JS oracle in lockstep WITHOUT mutating the process-global `process.env.TZ`
|
|
17
|
+
// (which `node --test` runs concurrently across FILES, so a `TZ` flip could leak into the
|
|
18
|
+
// timezone-specific assertions in app/mergesPerDay.test.ts), we drive the oracle with the SAME host
|
|
19
|
+
// zone SQLite uses by omitting its `timeZone` argument — `deriveMergesPerDay(rows)` defaults to the
|
|
20
|
+
// host zone. Both sides therefore bucket identically in any host zone, so no `day` string is
|
|
21
|
+
// hard-coded.
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { DatabaseSync } from "node:sqlite";
|
|
25
|
+
import { test } from "node:test";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { assert, assertEquals } from "#test-assert";
|
|
28
|
+
import { deriveMergesPerDay, type MergeAuditRow } from "./mergesPerDay.ts";
|
|
29
|
+
|
|
30
|
+
const MIGRATION = fileURLToPath(new URL("../db/migrations/062_merges_per_day_view.sql", import.meta.url));
|
|
31
|
+
|
|
32
|
+
/** A DB with the `merges` audit shape (004_merge.sql, FK-free like migration053.test.ts) + the view. */
|
|
33
|
+
function viewDb(): DatabaseSync {
|
|
34
|
+
const db = new DatabaseSync(":memory:");
|
|
35
|
+
db.exec(
|
|
36
|
+
`CREATE TABLE merges (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, pr_key TEXT NOT NULL, outcome TEXT NOT NULL,
|
|
38
|
+
method TEXT, detail TEXT, at TEXT NOT NULL);`,
|
|
39
|
+
);
|
|
40
|
+
db.exec(readFileSync(MIGRATION, "utf8"));
|
|
41
|
+
return db;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function seed(db: DatabaseSync, rows: readonly MergeAuditRow[]): void {
|
|
45
|
+
const ins = db.prepare("INSERT INTO merges (pr_key, outcome, at) VALUES (?, ?, ?)");
|
|
46
|
+
for (const r of rows) ins.run(r.pr_key, r.outcome, r.at);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The view's rows as `[day, merged, cumulative, bar]`, ordered like the Velocity page (day asc). */
|
|
50
|
+
function view(db: DatabaseSync): Array<[string, number, number, string]> {
|
|
51
|
+
return (
|
|
52
|
+
db.prepare("SELECT day, merged, cumulative, bar FROM merges_per_day_view ORDER BY day").all() as Array<
|
|
53
|
+
Record<string, unknown>
|
|
54
|
+
>
|
|
55
|
+
).map((r) => [r.day as string, Number(r.merged), Number(r.cumulative), r.bar as string]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const merged = (pr_key: string, at: string): MergeAuditRow => ({ pr_key, outcome: "merged", at });
|
|
59
|
+
|
|
60
|
+
test("merges_per_day_view reproduces the retired projection exactly (count, burn-up, bar)", () => {
|
|
61
|
+
const db = viewDb();
|
|
62
|
+
const rows: MergeAuditRow[] = [
|
|
63
|
+
// day A: 4 distinct PRs (the busiest day → widest bar), including a duplicate merged row.
|
|
64
|
+
merged("o/r#1", "2026-01-01T01:00:00Z"),
|
|
65
|
+
merged("o/r#2", "2026-01-01T02:00:00Z"),
|
|
66
|
+
merged("o/r#3", "2026-01-01T03:00:00Z"),
|
|
67
|
+
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
68
|
+
merged("o/r#4", "2026-01-01T05:00:00Z"), // retry / already-merged short-circuit → counts once
|
|
69
|
+
// day B: 1 PR → short but visible bar.
|
|
70
|
+
merged("o/r#5", "2026-01-02T10:00:00Z"),
|
|
71
|
+
// day C: 2 PRs.
|
|
72
|
+
merged("o/r#6", "2026-01-03T10:00:00Z"),
|
|
73
|
+
merged("o/r#7", "2026-01-03T11:00:00Z"),
|
|
74
|
+
// non-merged attempts must be ignored entirely.
|
|
75
|
+
{ pr_key: "o/r#8", outcome: "queued", at: "2026-01-02T12:00:00Z" },
|
|
76
|
+
{ pr_key: "o/r#9", outcome: "blocked", at: "2026-01-03T12:00:00Z" },
|
|
77
|
+
];
|
|
78
|
+
seed(db, rows);
|
|
79
|
+
|
|
80
|
+
const expected = deriveMergesPerDay(rows).map(
|
|
81
|
+
(d) => [d.day, d.merged, d.cumulative, d.bar] as [string, number, number, string],
|
|
82
|
+
);
|
|
83
|
+
assertEquals(view(db), expected);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("merges_per_day_view bar: full glyph run for the busiest day, min one glyph for a lone merge", () => {
|
|
87
|
+
const db = viewDb();
|
|
88
|
+
seed(db, [
|
|
89
|
+
merged("o/r#1", "2026-01-01T01:00:00Z"),
|
|
90
|
+
merged("o/r#2", "2026-01-01T02:00:00Z"),
|
|
91
|
+
merged("o/r#3", "2026-01-01T03:00:00Z"),
|
|
92
|
+
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
93
|
+
merged("o/r#5", "2026-01-02T01:00:00Z"),
|
|
94
|
+
]);
|
|
95
|
+
const rows = view(db);
|
|
96
|
+
const [a, b] = rows;
|
|
97
|
+
assertEquals(a[3], "█".repeat(30), "the busiest day fills the configured bar width (30)");
|
|
98
|
+
assert(a[3].length > b[3].length, "the busier day draws a longer bar");
|
|
99
|
+
assert([...b[3]].length >= 1, "a day with any merge draws at least one glyph");
|
|
100
|
+
assertEquals(view(db), deriveMergesPerDay([
|
|
101
|
+
merged("o/r#1", "2026-01-01T01:00:00Z"),
|
|
102
|
+
merged("o/r#2", "2026-01-01T02:00:00Z"),
|
|
103
|
+
merged("o/r#3", "2026-01-01T03:00:00Z"),
|
|
104
|
+
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
105
|
+
merged("o/r#5", "2026-01-02T01:00:00Z"),
|
|
106
|
+
]).map((d) => [d.day, d.merged, d.cumulative, d.bar]));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("merges_per_day_view is empty when no merges are recorded", () => {
|
|
110
|
+
const db = viewDb();
|
|
111
|
+
seed(db, [{ pr_key: "o/r#1", outcome: "queued", at: "2026-01-01T01:00:00Z" }]);
|
|
112
|
+
assertEquals(view(db), []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("merges_per_day_view buckets on the LOCAL calendar day, matching deriveMergesPerDay (issue #361)", () => {
|
|
116
|
+
// The view's `date(at, 'localtime')` and the oracle's default zone both resolve against the host
|
|
117
|
+
// zone, so two merges either side of a local-day boundary bucket identically on both sides — no
|
|
118
|
+
// matter which host zone the suite runs under.
|
|
119
|
+
const db = viewDb();
|
|
120
|
+
const rows: MergeAuditRow[] = [
|
|
121
|
+
merged("o/r#1", "2026-01-01T23:30:00Z"),
|
|
122
|
+
merged("o/r#2", "2026-01-02T00:30:00Z"),
|
|
123
|
+
];
|
|
124
|
+
seed(db, rows);
|
|
125
|
+
assertEquals(view(db), deriveMergesPerDay(rows).map((d) => [d.day, d.merged, d.cumulative, d.bar]));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// A tiny deterministic PRNG (mulberry32) so the property check runs the SAME audit rows every time —
|
|
129
|
+
// non-determinism (`Math.random()`) would make a failure impossible to reproduce (see "no flaky
|
|
130
|
+
// tests" in AGENTS.md). Seeded once with a fixed constant.
|
|
131
|
+
function mulberry32(seed: number): () => number {
|
|
132
|
+
let a = seed >>> 0;
|
|
133
|
+
return () => {
|
|
134
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
135
|
+
let t = a;
|
|
136
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
137
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
138
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("merges_per_day_view reproduces the projection across many random audits (property check)", () => {
|
|
143
|
+
const db = viewDb();
|
|
144
|
+
const outcomes = ["merged", "merged", "merged", "queued", "blocked"];
|
|
145
|
+
const rand = mulberry32(0x9e3779b9); // fixed seed → the same 200 trials on every run.
|
|
146
|
+
for (let trial = 0; trial < 200; trial++) {
|
|
147
|
+
db.exec("DELETE FROM merges");
|
|
148
|
+
const rows: MergeAuditRow[] = [];
|
|
149
|
+
const n = 1 + Math.floor(rand() * 40);
|
|
150
|
+
for (let i = 0; i < n; i++) {
|
|
151
|
+
const day = 1 + Math.floor(rand() * 9);
|
|
152
|
+
const hh = String(Math.floor(rand() * 24)).padStart(2, "0");
|
|
153
|
+
rows.push({
|
|
154
|
+
pr_key: `o/r#${1 + Math.floor(rand() * 12)}`,
|
|
155
|
+
outcome: outcomes[Math.floor(rand() * outcomes.length)],
|
|
156
|
+
at: `2026-01-0${day}T${hh}:00:00Z`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
seed(db, rows);
|
|
160
|
+
assertEquals(
|
|
161
|
+
view(db),
|
|
162
|
+
deriveMergesPerDay(rows).map((d) => [d.day, d.merged, d.cumulative, d.bar]),
|
|
163
|
+
`random trial ${trial} diverged from the projection`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|