@nanobpm/nano-workforce 0.165.0 → 0.167.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 +12 -0
- package/app/backfillAcknowledgedAt.test.ts +121 -0
- package/app/delivery.ts +15 -13
- package/app/deliveryGraphReadModel.test.ts +44 -8
- package/app/deliveryGraphReadModel.ts +19 -2
- package/app/deliveryGraphRun.ts +5 -0
- package/app/epicBucket.test.ts +9 -3
- package/app/featureReadModel.ts +7 -11
- package/app/fineGrainedCells.test.ts +35 -17
- package/app/listBucket.ts +86 -0
- package/app/planReadModel.test.ts +29 -19
- package/app/planReadModel.ts +32 -26
- package/app/pullRequestReadModel.test.ts +208 -0
- package/app/pullRequestReadModel.ts +76 -0
- package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
- package/db/migrations/094_pull_requests_read_model.sql +65 -0
- package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
- package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
- package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
- package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
- package/openapi.yaml +98 -0
- package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
- package/operations/acknowledgeDeliveryGraph.ts +58 -0
- package/operations/acknowledgePr.test.ts +94 -0
- package/operations/acknowledgePr.ts +62 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +12 -3
- package/pages/home.page.json +18 -27
- package/pages/overview.page.json +26 -17
- package/resources/processes/feature.bpmn +8 -6
- package/scripts/pages-contract.test.ts +80 -18
- package/workers/record-results/worker.test.ts +5 -3
|
@@ -120,11 +120,11 @@
|
|
|
120
120
|
"source": "app",
|
|
121
121
|
"table": "delivery_graph_read_model",
|
|
122
122
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
123
|
-
"filter": [{ "field": "
|
|
123
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
124
124
|
},
|
|
125
125
|
"tabs": [
|
|
126
|
-
{ "label": "In-flight", "filter": [{ "field": "
|
|
127
|
-
{ "label": "History", "filter": [{ "field": "
|
|
126
|
+
{ "label": "In-flight", "filter": [{ "field": "list_bucket", "in": ["active"] }] },
|
|
127
|
+
{ "label": "History", "filter": [{ "field": "list_bucket", "in": ["history"] }] },
|
|
128
128
|
{ "label": "All", "filter": [] }
|
|
129
129
|
],
|
|
130
130
|
"columns": [
|
|
@@ -171,6 +171,15 @@
|
|
|
171
171
|
"path": "/app/api/actions/delivery-graph/library/save",
|
|
172
172
|
"body": { "name": "{{row.title}}", "digest": "{{row.digest}}" }
|
|
173
173
|
}
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"label": "Dismiss",
|
|
177
|
+
"confirm": "Dismiss this finished delivery graph? It acknowledges the terminal run and files it under History.",
|
|
178
|
+
"showWhenField": "ack_open",
|
|
179
|
+
"action": {
|
|
180
|
+
"path": "/app/api/actions/acknowledge-delivery-graph",
|
|
181
|
+
"body": { "run_key": "{{row.run_key}}" }
|
|
182
|
+
}
|
|
174
183
|
}
|
|
175
184
|
]
|
|
176
185
|
}
|
package/pages/home.page.json
CHANGED
|
@@ -119,23 +119,15 @@
|
|
|
119
119
|
"data": {
|
|
120
120
|
"kind": "datasource",
|
|
121
121
|
"source": "app",
|
|
122
|
-
"table": "
|
|
122
|
+
"table": "pull_requests_read_model",
|
|
123
123
|
"orderBy": {
|
|
124
124
|
"field": "updated_at",
|
|
125
125
|
"dir": "desc"
|
|
126
126
|
},
|
|
127
127
|
"filter": [
|
|
128
128
|
{
|
|
129
|
-
"field": "
|
|
130
|
-
"in": [
|
|
131
|
-
"converging",
|
|
132
|
-
"waiting_review",
|
|
133
|
-
"escalated",
|
|
134
|
-
"waiting_deps",
|
|
135
|
-
"waiting_merge",
|
|
136
|
-
"queued",
|
|
137
|
-
"merging"
|
|
138
|
-
]
|
|
129
|
+
"field": "list_bucket",
|
|
130
|
+
"in": ["active"]
|
|
139
131
|
}
|
|
140
132
|
]
|
|
141
133
|
},
|
|
@@ -144,16 +136,8 @@
|
|
|
144
136
|
"label": "Active",
|
|
145
137
|
"filter": [
|
|
146
138
|
{
|
|
147
|
-
"field": "
|
|
148
|
-
"in": [
|
|
149
|
-
"converging",
|
|
150
|
-
"waiting_review",
|
|
151
|
-
"escalated",
|
|
152
|
-
"waiting_deps",
|
|
153
|
-
"waiting_merge",
|
|
154
|
-
"queued",
|
|
155
|
-
"merging"
|
|
156
|
-
]
|
|
139
|
+
"field": "list_bucket",
|
|
140
|
+
"in": ["active"]
|
|
157
141
|
}
|
|
158
142
|
]
|
|
159
143
|
},
|
|
@@ -161,12 +145,8 @@
|
|
|
161
145
|
"label": "History",
|
|
162
146
|
"filter": [
|
|
163
147
|
{
|
|
164
|
-
"field": "
|
|
165
|
-
"in": [
|
|
166
|
-
"converged",
|
|
167
|
-
"merged",
|
|
168
|
-
"abandoned"
|
|
169
|
-
]
|
|
148
|
+
"field": "list_bucket",
|
|
149
|
+
"in": ["history"]
|
|
170
150
|
}
|
|
171
151
|
]
|
|
172
152
|
}
|
|
@@ -224,6 +204,17 @@
|
|
|
224
204
|
"processInstanceKey": "{{row.process_key}}"
|
|
225
205
|
}
|
|
226
206
|
}
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"label": "Dismiss",
|
|
210
|
+
"confirm": "Dismiss this finished PR? It acknowledges the terminal pull request and files it under History.",
|
|
211
|
+
"showWhenField": "ack_open",
|
|
212
|
+
"action": {
|
|
213
|
+
"path": "/app/api/actions/acknowledge-pr",
|
|
214
|
+
"body": {
|
|
215
|
+
"pr_key": "{{row.pr_key}}"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
227
218
|
}
|
|
228
219
|
],
|
|
229
220
|
"detail": {
|
package/pages/overview.page.json
CHANGED
|
@@ -101,22 +101,9 @@
|
|
|
101
101
|
"data": {
|
|
102
102
|
"kind": "datasource",
|
|
103
103
|
"source": "app",
|
|
104
|
-
"table": "
|
|
104
|
+
"table": "pull_requests_read_model",
|
|
105
105
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
106
|
-
"filter": [
|
|
107
|
-
{
|
|
108
|
-
"field": "status",
|
|
109
|
-
"in": [
|
|
110
|
-
"converging",
|
|
111
|
-
"waiting_review",
|
|
112
|
-
"escalated",
|
|
113
|
-
"waiting_deps",
|
|
114
|
-
"waiting_merge",
|
|
115
|
-
"queued",
|
|
116
|
-
"merging"
|
|
117
|
-
]
|
|
118
|
-
}
|
|
119
|
-
]
|
|
106
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
120
107
|
},
|
|
121
108
|
"columns": [
|
|
122
109
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
@@ -124,6 +111,17 @@
|
|
|
124
111
|
{ "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
|
|
125
112
|
{ "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
|
|
126
113
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
114
|
+
],
|
|
115
|
+
"rowActions": [
|
|
116
|
+
{
|
|
117
|
+
"label": "Dismiss",
|
|
118
|
+
"confirm": "Dismiss this finished PR? It acknowledges the terminal pull request and files it under History.",
|
|
119
|
+
"showWhenField": "ack_open",
|
|
120
|
+
"action": {
|
|
121
|
+
"path": "/app/api/actions/acknowledge-pr",
|
|
122
|
+
"body": { "pr_key": "{{row.pr_key}}" }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
127
125
|
]
|
|
128
126
|
}
|
|
129
127
|
},
|
|
@@ -220,7 +218,7 @@
|
|
|
220
218
|
"source": "app",
|
|
221
219
|
"table": "delivery_graph_read_model",
|
|
222
220
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
223
|
-
"filter": [{ "field": "
|
|
221
|
+
"filter": [{ "field": "list_bucket", "in": ["active"] }]
|
|
224
222
|
},
|
|
225
223
|
"columns": [
|
|
226
224
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "run_key", "truncate": true, "width": "30%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
@@ -252,7 +250,18 @@
|
|
|
252
250
|
{ "field": "human_node_count", "label": "Human nodes" },
|
|
253
251
|
{ "field": "side_effect_count", "label": "Side effects" }
|
|
254
252
|
]
|
|
255
|
-
}
|
|
253
|
+
},
|
|
254
|
+
"rowActions": [
|
|
255
|
+
{
|
|
256
|
+
"label": "Dismiss",
|
|
257
|
+
"confirm": "Dismiss this finished delivery graph? It acknowledges the terminal run and files it under History.",
|
|
258
|
+
"showWhenField": "ack_open",
|
|
259
|
+
"action": {
|
|
260
|
+
"path": "/app/api/actions/acknowledge-delivery-graph",
|
|
261
|
+
"body": { "run_key": "{{row.run_key}}" }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
]
|
|
256
265
|
}
|
|
257
266
|
}
|
|
258
267
|
]
|
|
@@ -264,16 +264,18 @@
|
|
|
264
264
|
<bpmn:outgoing>f_converge</bpmn:outgoing>
|
|
265
265
|
<bpmn:outgoing>f_noConverge</bpmn:outgoing>
|
|
266
266
|
</bpmn:exclusiveGateway>
|
|
267
|
-
<bpmn:
|
|
267
|
+
<bpmn:callActivity id="converge" name="Converge cell">
|
|
268
268
|
<bpmn:extensionElements>
|
|
269
|
-
<zeebe:
|
|
270
|
-
<zeebe:
|
|
271
|
-
<zeebe:
|
|
272
|
-
|
|
269
|
+
<zeebe:calledElement processId="converge-cell" />
|
|
270
|
+
<zeebe:ioMapping>
|
|
271
|
+
<zeebe:input source="=featureKey" target="featureKey" />
|
|
272
|
+
<zeebe:input source="=prKey" target="prKey" />
|
|
273
|
+
<zeebe:input source="=autoMerge" target="autoMerge" />
|
|
274
|
+
</zeebe:ioMapping>
|
|
273
275
|
</bpmn:extensionElements>
|
|
274
276
|
<bpmn:incoming>f_converge</bpmn:incoming>
|
|
275
277
|
<bpmn:outgoing>f_toEndConverged</bpmn:outgoing>
|
|
276
|
-
</bpmn:
|
|
278
|
+
</bpmn:callActivity>
|
|
277
279
|
<bpmn:endEvent id="End" name="Done">
|
|
278
280
|
<bpmn:incoming>f_noConverge</bpmn:incoming>
|
|
279
281
|
<bpmn:incoming>f_toEndConverged</bpmn:incoming>
|
|
@@ -319,27 +319,21 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
319
319
|
|
|
320
320
|
// Four collapsible active-work sections, one per dispatch surface, each with a
|
|
321
321
|
// live count in its header (showCount) and a persisted collapse toggle (collapsible). Each filters
|
|
322
|
-
// its Active list on a `{field, in:[...]}` predicate.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// shared byte-for-byte with the Feature tab); the epic surface binds `plan_read_model` (epic #412),
|
|
330
|
-
// not the raw `plans` table. PRs / delivery graphs still bucket on `status` (see #637 follow-up).
|
|
322
|
+
// its Active list on a `{field, in:[...]}` predicate. ALL FOUR surfaces now bucket on the DERIVED
|
|
323
|
+
// `list_bucket` — NOT raw `status` — over their read-model VIEW (features: issue #637; epics: #298;
|
|
324
|
+
// PRs + delivery graphs: issue #641 — retiring the last two base-`status` allowlists), so a terminal-
|
|
325
|
+
// but-unacknowledged item stays in its in-flight section until an operator dismisses it, instead of
|
|
326
|
+
// vanishing the instant `status` reads terminal. Guarding the field here is the regression guard for
|
|
327
|
+
// that defect class. Each surface binds its declare-once read-model VIEW (the single source of truth
|
|
328
|
+
// for the `list_bucket` activeness predicate), never a raw base table.
|
|
331
329
|
const expected: Record<string, { field: string; in: string[] }> = {
|
|
332
|
-
|
|
333
|
-
field: "status",
|
|
334
|
-
in: ["converging", "waiting_review", "escalated", "waiting_deps", "waiting_merge", "queued", "merging"],
|
|
335
|
-
},
|
|
330
|
+
pull_requests_read_model: { field: "list_bucket", in: ["active"] },
|
|
336
331
|
plan_read_model: { field: "list_bucket", in: ["active"] },
|
|
337
332
|
feature_read_model: { field: "list_bucket", in: ["active"] },
|
|
338
|
-
// The 4th dispatch surface (issue #386) — active delivery graphs.
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
delivery_graph_read_model: { field: "status", in: ["awaiting-approval", "running"] },
|
|
333
|
+
// The 4th dispatch surface (issue #386) — active delivery graphs. Binds the derived
|
|
334
|
+
// `delivery_graph_read_model` VIEW (S7 / #541), now filtered on `list_bucket` (issue #641) so a
|
|
335
|
+
// terminal run stays Active until dismissed rather than dropping on its base `status`.
|
|
336
|
+
delivery_graph_read_model: { field: "list_bucket", in: ["active"] },
|
|
343
337
|
};
|
|
344
338
|
const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
|
|
345
339
|
for (const [table, { field, in: values }] of Object.entries(expected)) {
|
|
@@ -357,6 +351,74 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
357
351
|
});
|
|
358
352
|
|
|
359
353
|
|
|
354
|
+
test("issue #641: no Active/History grid filters a base-`status` allowlist — every dispatch Active list buckets on a derived read-model VIEW", async () => {
|
|
355
|
+
// The drift class this closes (the last mile of #637): an "Active …" dispatch list that re-encodes
|
|
356
|
+
// activeness as a hardcoded base-`status` allowlist (`{ field: "status", in: [...] }`) instead of the
|
|
357
|
+
// declare-once derived `list_bucket` over its read-model VIEW. Such an allowlist silently drops a row
|
|
358
|
+
// out of Active the instant its `status` leaves the set — with NO operator dismiss — and must be kept
|
|
359
|
+
// hand-synced with every status rename. #641 retired the last two (PRs + delivery graphs); this guard
|
|
360
|
+
// makes the whole class a BUILD FAILURE so it can never creep back in on a new/edited grid.
|
|
361
|
+
//
|
|
362
|
+
// SCOPE — an "Active/History surface" is a top-level grid that partitions rows into active vs.
|
|
363
|
+
// finished: either its title starts with "Active " (the Overview sections) OR it carries a `History`
|
|
364
|
+
// tab (the Feature/Epic/PR/Delivery-Graph/lineage grids). This deliberately EXCLUDES the param-scoped
|
|
365
|
+
// per-epic slice grids (epic-detail's "Wave state" Active/Skipped/All, "Trial-merge results"
|
|
366
|
+
// Needs-attention/Clean/All), which legitimately filter a domain `status`/`result` within one epic —
|
|
367
|
+
// they have no `History` tab and no "Active " title, so they are not activeness partitions.
|
|
368
|
+
const BASE_TABLES = new Set(["pull_requests", "plans", "feature_runs", "delivery_graph_runs"]);
|
|
369
|
+
const grids: Json[] = [];
|
|
370
|
+
const walk = (n: Json): void => {
|
|
371
|
+
if (Array.isArray(n)) return n.forEach(walk);
|
|
372
|
+
if (n && typeof n === "object") {
|
|
373
|
+
if (n.type === "dataGrid") grids.push(n);
|
|
374
|
+
for (const v of Object.values(n)) walk(v);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
for (const e of readdirSync(`${ROOT}pages`, { withFileTypes: true })) {
|
|
378
|
+
if (!e.isFile() || !e.name.endsWith(".page.json")) continue;
|
|
379
|
+
const page = JSON.parse(readFileSync(`${ROOT}pages/${e.name}`, "utf8"));
|
|
380
|
+
walk(page.nodes ?? page);
|
|
381
|
+
// Tag each grid with its page for messages.
|
|
382
|
+
for (const g of grids) if (g.__page === undefined) g.__page = e.name;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let surfaces = 0;
|
|
386
|
+
for (const g of grids) {
|
|
387
|
+
const props = g.props ?? {};
|
|
388
|
+
const title: string = typeof props.title === "string" ? props.title : "";
|
|
389
|
+
const tabs: Json[] = Array.isArray(props.tabs) ? props.tabs : [];
|
|
390
|
+
const hasHistoryTab = tabs.some((t) => typeof t?.label === "string" && /history/i.test(t.label));
|
|
391
|
+
const activeTitle = /^active\s/i.test(title);
|
|
392
|
+
if (!hasHistoryTab && !activeTitle) continue; // not an activeness partition — skip
|
|
393
|
+
surfaces++;
|
|
394
|
+
|
|
395
|
+
const table: string = g.props?.data?.table ?? "";
|
|
396
|
+
// 1) It must bind a DERIVED read-model relation, never a raw base table (whose only activeness
|
|
397
|
+
// signal is the base `status` this guard forbids filtering on).
|
|
398
|
+
assert(
|
|
399
|
+
!BASE_TABLES.has(table),
|
|
400
|
+
`${g.__page}: Active/History grid "${title || tabs.map((t) => t.label).join("/")}" binds the raw base table "${table}" — bind its derived read-model VIEW (…_read_model) so it can filter the declared list_bucket`,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// 2) No filter — the grid's main filter OR any tab filter — may re-encode activeness as a
|
|
404
|
+
// base-`status` allowlist. (A domain filter on another column is fine; only `status IN […]` is
|
|
405
|
+
// the banned drift surface.)
|
|
406
|
+
const allFilters: Json[] = [g.props?.data?.filter, ...tabs.map((t) => t?.filter)];
|
|
407
|
+
for (const filter of allFilters) {
|
|
408
|
+
if (!Array.isArray(filter)) continue;
|
|
409
|
+
for (const pred of filter) {
|
|
410
|
+
assert(
|
|
411
|
+
!(pred?.field === "status" && (Array.isArray(pred.in) || pred.eq !== undefined)),
|
|
412
|
+
`${g.__page}: Active/History grid "${title || tabs.map((t) => t.label).join("/")}" filters a base-\`status\` allowlist ` +
|
|
413
|
+
`(${JSON.stringify(pred)}) — bucket on the derived \`list_bucket\` over the read-model VIEW instead (issue #641).`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
assert(surfaces >= 6, `expected to find the dispatch Active/History surfaces, found only ${surfaces}`);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
|
|
360
422
|
test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, page, detail, overview)", async () => {
|
|
361
423
|
// 1) A `Delivery Graphs` nav tab in the single-source nav, pointing at the delivery-graphs page.
|
|
362
424
|
const nav = JSON.parse(readFileSync(`${ROOT}pages/_nav.json`, "utf8"));
|
|
@@ -78,9 +78,11 @@ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () =
|
|
|
78
78
|
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
79
79
|
assertEquals(plan.status, "failed");
|
|
80
80
|
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
81
|
-
// `list_bucket` is derived by the `plan_read_model` VIEW
|
|
82
|
-
// (
|
|
83
|
-
|
|
81
|
+
// `list_bucket` is derived by the `plan_read_model` VIEW: under the uniform acknowledge-to-dismiss
|
|
82
|
+
// rule (issue #641) a terminal-but-UNACKNOWLEDGED epic — including a `failed` one — STAYS in Active
|
|
83
|
+
// (offering the operator a Dismiss) until it is acknowledged, rather than vanishing straight to
|
|
84
|
+
// History. Cross-checked against the pure `deriveEpicBucket` oracle the VIEW mirrors.
|
|
85
|
+
assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "active");
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|