@nanobpm/nano-workforce 0.90.0 → 0.91.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 +7 -0
- package/app/pollUserTasks.test.ts +7 -2
- package/app/service.ts +5 -0
- package/app/userTasks.test.ts +24 -0
- package/app/userTasks.ts +11 -0
- package/db/migrations/043_user_tasks_subject_title.sql +24 -0
- package/package.json +1 -1
- package/pages/tasks.page.json +30 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.91.0](https://github.com/nanobpm/nano-workforce/compare/v0.90.0...v0.91.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **tasks:** show subject title with repo/issue# as subtitle ([#308](https://github.com/nanobpm/nano-workforce/issues/308)) ([#311](https://github.com/nanobpm/nano-workforce/issues/311)) ([5771b38](https://github.com/nanobpm/nano-workforce/commit/5771b38abc33f4f88329d69c808214dfd42f5113))
|
|
7
|
+
|
|
1
8
|
# [0.90.0](https://github.com/nanobpm/nano-workforce/compare/v0.89.0...v0.90.0) (2026-08-19)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -78,6 +78,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
78
78
|
status: "escalated",
|
|
79
79
|
process_key: "fp-10",
|
|
80
80
|
issue_url: "https://github.com/o/r/issues/10",
|
|
81
|
+
title: "Add the framework selector",
|
|
81
82
|
escalation_user_task_key: "ut-feat",
|
|
82
83
|
escalation_question: "which framework?",
|
|
83
84
|
blocked_user_task_key: null,
|
|
@@ -85,7 +86,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
85
86
|
},
|
|
86
87
|
],
|
|
87
88
|
plans: [
|
|
88
|
-
{ plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20" },
|
|
89
|
+
{ plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
|
|
89
90
|
{ plan_key: "o/r#21", status: "done", process_key: "pp-21", issue_url: "https://github.com/o/r/issues/21" },
|
|
90
91
|
],
|
|
91
92
|
plan_reviews: [
|
|
@@ -96,7 +97,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
96
97
|
{ id: 1, plan_key: "o/r#20", wave: 0, result: "suite-failed", summary: "wave 0 red", resolved: 0 },
|
|
97
98
|
],
|
|
98
99
|
pull_requests: [
|
|
99
|
-
{ pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30" },
|
|
100
|
+
{ pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30", title: "Resolve the reviews" },
|
|
100
101
|
],
|
|
101
102
|
escalations: [{ id: 1, pr_key: "o/r#30", status: "open", question: "conflicting reviews" }],
|
|
102
103
|
});
|
|
@@ -115,13 +116,17 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
115
116
|
assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan", "ut-pr", "ut-trial"]);
|
|
116
117
|
assertEquals(byKey["ut-feat"].kind_label, "Feature escalation");
|
|
117
118
|
assertEquals(byKey["ut-feat"].question, "which framework?");
|
|
119
|
+
assertEquals(byKey["ut-feat"].subject_title, "Add the framework selector");
|
|
118
120
|
assertEquals(byKey["ut-plan"].kind_label, "Plan review");
|
|
119
121
|
assertEquals(byKey["ut-plan"].question, "scope too broad");
|
|
122
|
+
assertEquals(byKey["ut-plan"].subject_title, "Broaden the epic scope");
|
|
120
123
|
assertEquals(byKey["ut-trial"].kind_label, "Trial merge");
|
|
121
124
|
assertEquals(byKey["ut-trial"].question, "wave 0 red");
|
|
125
|
+
assertEquals(byKey["ut-trial"].subject_title, "Broaden the epic scope");
|
|
122
126
|
assertEquals(byKey["ut-pr"].kind_label, "PR review");
|
|
123
127
|
assertEquals(byKey["ut-pr"].subject_type, "pr");
|
|
124
128
|
assertEquals(byKey["ut-pr"].question, "conflicting reviews");
|
|
129
|
+
assertEquals(byKey["ut-pr"].subject_title, "Resolve the reviews");
|
|
125
130
|
});
|
|
126
131
|
|
|
127
132
|
test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
|
package/app/service.ts
CHANGED
|
@@ -1626,6 +1626,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1626
1626
|
elementId: FEATURE_ESCALATION_ELEMENT,
|
|
1627
1627
|
subjectType: "feature",
|
|
1628
1628
|
subjectKey: run.feature_key,
|
|
1629
|
+
subjectTitle: run.title,
|
|
1629
1630
|
subjectUrl: run.issue_url,
|
|
1630
1631
|
question: run.escalation_question,
|
|
1631
1632
|
processKey: run.process_key,
|
|
@@ -1642,6 +1643,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1642
1643
|
elementId: FEATURE_BLOCKED_ELEMENT,
|
|
1643
1644
|
subjectType: "feature",
|
|
1644
1645
|
subjectKey: run.feature_key,
|
|
1646
|
+
subjectTitle: run.title,
|
|
1645
1647
|
subjectUrl: run.issue_url,
|
|
1646
1648
|
question: run.delivery_label,
|
|
1647
1649
|
processKey: run.process_key,
|
|
@@ -1680,6 +1682,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1680
1682
|
elementId: PLAN_REVIEW_ELEMENT,
|
|
1681
1683
|
subjectType: "plan",
|
|
1682
1684
|
subjectKey: plan.plan_key,
|
|
1685
|
+
subjectTitle: plan.title,
|
|
1683
1686
|
subjectUrl: plan.issue_url,
|
|
1684
1687
|
question,
|
|
1685
1688
|
processKey: plan.process_key,
|
|
@@ -1696,6 +1699,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1696
1699
|
elementId: TRIAL_MERGE_ELEMENT,
|
|
1697
1700
|
subjectType: "plan",
|
|
1698
1701
|
subjectKey: plan.plan_key,
|
|
1702
|
+
subjectTitle: plan.title,
|
|
1699
1703
|
subjectUrl: plan.issue_url,
|
|
1700
1704
|
question,
|
|
1701
1705
|
processKey: plan.process_key,
|
|
@@ -1735,6 +1739,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1735
1739
|
elementId: t.elementId,
|
|
1736
1740
|
subjectType: "pr",
|
|
1737
1741
|
subjectKey: pr.pr_key,
|
|
1742
|
+
subjectTitle: pr.title,
|
|
1738
1743
|
subjectUrl: pr.url,
|
|
1739
1744
|
question,
|
|
1740
1745
|
processKey: pr.process_key,
|
package/app/userTasks.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ test("buildUserTaskRow: a plan-review task becomes a labelled row with its findi
|
|
|
41
41
|
kind_label: "Plan review",
|
|
42
42
|
subject_type: "plan",
|
|
43
43
|
subject_key: "o/r#1",
|
|
44
|
+
subject_title: "o/r#1",
|
|
44
45
|
subject_url: "https://github.com/o/r/issues/1",
|
|
45
46
|
question: "cap reached: revise scope",
|
|
46
47
|
process_key: "pk-1",
|
|
@@ -80,6 +81,28 @@ test("buildUserTaskRow: a blank userTaskKey or subjectKey yields null", () => {
|
|
|
80
81
|
);
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
test("buildUserTaskRow: subject_title carries the subject title, trimmed, and coalesces to subject_key when absent/blank (issue #308)", () => {
|
|
85
|
+
const titled = buildUserTaskRow(
|
|
86
|
+
{
|
|
87
|
+
userTaskKey: "ut-t1",
|
|
88
|
+
elementId: PLAN_REVIEW_ELEMENT,
|
|
89
|
+
subjectType: "plan",
|
|
90
|
+
subjectKey: "o/r#1",
|
|
91
|
+
subjectTitle: " Add the widget ",
|
|
92
|
+
},
|
|
93
|
+
AT,
|
|
94
|
+
);
|
|
95
|
+
assertEquals(titled?.subject_title, "Add the widget");
|
|
96
|
+
|
|
97
|
+
for (const subjectTitle of [undefined, null, " "]) {
|
|
98
|
+
const row = buildUserTaskRow(
|
|
99
|
+
{ userTaskKey: "ut-t2", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#2", subjectTitle },
|
|
100
|
+
AT,
|
|
101
|
+
);
|
|
102
|
+
assertEquals(row?.subject_title, "o/r#2");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
83
106
|
function row(key: string, extra: Partial<UserTaskRow> = {}): UserTaskRow {
|
|
84
107
|
return {
|
|
85
108
|
user_task_key: key,
|
|
@@ -87,6 +110,7 @@ function row(key: string, extra: Partial<UserTaskRow> = {}): UserTaskRow {
|
|
|
87
110
|
kind_label: "Plan review",
|
|
88
111
|
subject_type: "plan",
|
|
89
112
|
subject_key: "o/r#1",
|
|
113
|
+
subject_title: "o/r#1",
|
|
90
114
|
subject_url: null,
|
|
91
115
|
question: null,
|
|
92
116
|
process_key: null,
|
package/app/userTasks.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface UserTaskRow {
|
|
|
51
51
|
kind_label: string;
|
|
52
52
|
subject_type: string;
|
|
53
53
|
subject_key: string;
|
|
54
|
+
/** The subject's human-readable title (`feature_runs`/`plans`/`pull_requests`.`title`), derived by
|
|
55
|
+
* `pollUserTasks` from the subject row keyed on `subject_key` and coalesced to `subject_key` at
|
|
56
|
+
* build time so the title-led grids never render a blank primary line (issue #308). */
|
|
57
|
+
subject_title: string;
|
|
54
58
|
subject_url: string | null;
|
|
55
59
|
question: string | null;
|
|
56
60
|
process_key: string | null;
|
|
@@ -78,6 +82,10 @@ export interface UserTaskContext {
|
|
|
78
82
|
elementId: string;
|
|
79
83
|
subjectType: "feature" | "plan" | "pr";
|
|
80
84
|
subjectKey: string;
|
|
85
|
+
/** The subject's human-readable title from its own row (`feature_runs`/`plans`/`pull_requests`.
|
|
86
|
+
* `title`). Optional/blank tolerated — `buildUserTaskRow` coalesces it to `subjectKey` so the
|
|
87
|
+
* projected `subject_title` is never blank. */
|
|
88
|
+
subjectTitle?: string | null;
|
|
81
89
|
subjectUrl?: string | null;
|
|
82
90
|
question?: string | null;
|
|
83
91
|
processKey?: string | null;
|
|
@@ -93,12 +101,14 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
|
|
|
93
101
|
const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
|
|
94
102
|
if (!userTaskKey || !subjectKey || !kindLabel) return null;
|
|
95
103
|
const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
|
|
104
|
+
const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
|
|
96
105
|
return {
|
|
97
106
|
user_task_key: userTaskKey,
|
|
98
107
|
element_id: ctx.elementId,
|
|
99
108
|
kind_label: kindLabel,
|
|
100
109
|
subject_type: ctx.subjectType,
|
|
101
110
|
subject_key: subjectKey,
|
|
111
|
+
subject_title: subjectTitle,
|
|
102
112
|
subject_url: ctx.subjectUrl ?? null,
|
|
103
113
|
question,
|
|
104
114
|
process_key: ctx.processKey ?? null,
|
|
@@ -124,6 +134,7 @@ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
|
|
|
124
134
|
a.kind_label === b.kind_label &&
|
|
125
135
|
a.subject_type === b.subject_type &&
|
|
126
136
|
a.subject_key === b.subject_key &&
|
|
137
|
+
a.subject_title === b.subject_title &&
|
|
127
138
|
a.subject_url === b.subject_url &&
|
|
128
139
|
a.question === b.question &&
|
|
129
140
|
a.process_key === b.process_key
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Surface the subject's human-readable title as the primary identity on the Tasks
|
|
2
|
+
-- grids (issue #308), mirroring the Features list. Every `user_tasks` row was keyed
|
|
3
|
+
-- only by `subject_key` (`owner/repo#N`) — meaningful while you remember the number,
|
|
4
|
+
-- opaque hours later. The Features list already leads with `{{title}}` and shows the
|
|
5
|
+
-- key as a subtitle; the Tasks grids could only show the bare key.
|
|
6
|
+
--
|
|
7
|
+
-- The title is DERIVED (no new fetch): each escalation's subject already persists a
|
|
8
|
+
-- title in a surviving table keyed by `subject_key` — `feature_runs.title`,
|
|
9
|
+
-- `plans.title`, `pull_requests.title`. `pollUserTasks` reads it from the subject row
|
|
10
|
+
-- already in scope this pass and coalesces to `subject_key` at write time so the
|
|
11
|
+
-- column is ALWAYS non-blank — the grid's `{{subject_title}}` template then needs no
|
|
12
|
+
-- fallback and a missing subject title still shows a usable identity.
|
|
13
|
+
--
|
|
14
|
+
-- Forward-only, additive (expand): nullable with no default, then backfilled in the
|
|
15
|
+
-- same migration so the column is non-blank immediately — pre-#308 rows would otherwise
|
|
16
|
+
-- grandfather in as NULL and the grid's `{{subject_title}}` primary line would render a
|
|
17
|
+
-- blank/"null" identity for the window between deploy and the first `pollUserTasks` pass.
|
|
18
|
+
-- The backfill coalesces existing rows to `subject_key` (matching the write-time coalesce
|
|
19
|
+
-- in `pollUserTasks`, which re-derives the real title in place on the next poll — a
|
|
20
|
+
-- completed task's row is deleted, not migrated). Idempotent: re-running is a no-op once
|
|
21
|
+
-- set. Numbered after the current highest prefix on origin/main (042); the runner wraps
|
|
22
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
23
|
+
ALTER TABLE user_tasks ADD COLUMN subject_title TEXT;
|
|
24
|
+
UPDATE user_tasks SET subject_title = subject_key WHERE subject_title IS NULL OR trim(subject_title) = '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.0",
|
|
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",
|
package/pages/tasks.page.json
CHANGED
|
@@ -84,8 +84,12 @@
|
|
|
84
84
|
},
|
|
85
85
|
"columns": [
|
|
86
86
|
{
|
|
87
|
-
"field": "
|
|
88
|
-
"
|
|
87
|
+
"field": "subject_title",
|
|
88
|
+
"template": "{{subject_title}}",
|
|
89
|
+
"header": "Item",
|
|
90
|
+
"subtitleField": "subject_key",
|
|
91
|
+
"truncate": true,
|
|
92
|
+
"linkField": "subject_url"
|
|
89
93
|
},
|
|
90
94
|
{
|
|
91
95
|
"field": "subject_url",
|
|
@@ -141,8 +145,12 @@
|
|
|
141
145
|
},
|
|
142
146
|
"columns": [
|
|
143
147
|
{
|
|
144
|
-
"field": "
|
|
145
|
-
"
|
|
148
|
+
"field": "subject_title",
|
|
149
|
+
"template": "{{subject_title}}",
|
|
150
|
+
"header": "Item",
|
|
151
|
+
"subtitleField": "subject_key",
|
|
152
|
+
"truncate": true,
|
|
153
|
+
"linkField": "subject_url"
|
|
146
154
|
},
|
|
147
155
|
{
|
|
148
156
|
"field": "question",
|
|
@@ -202,8 +210,12 @@
|
|
|
202
210
|
},
|
|
203
211
|
"columns": [
|
|
204
212
|
{
|
|
205
|
-
"field": "
|
|
206
|
-
"
|
|
213
|
+
"field": "subject_title",
|
|
214
|
+
"template": "{{subject_title}}",
|
|
215
|
+
"header": "Item",
|
|
216
|
+
"subtitleField": "subject_key",
|
|
217
|
+
"truncate": true,
|
|
218
|
+
"linkField": "subject_url"
|
|
207
219
|
},
|
|
208
220
|
{
|
|
209
221
|
"field": "question",
|
|
@@ -264,8 +276,12 @@
|
|
|
264
276
|
},
|
|
265
277
|
"columns": [
|
|
266
278
|
{
|
|
267
|
-
"field": "
|
|
268
|
-
"
|
|
279
|
+
"field": "subject_title",
|
|
280
|
+
"template": "{{subject_title}}",
|
|
281
|
+
"header": "Item",
|
|
282
|
+
"subtitleField": "subject_key",
|
|
283
|
+
"truncate": true,
|
|
284
|
+
"linkField": "subject_url"
|
|
269
285
|
},
|
|
270
286
|
{
|
|
271
287
|
"field": "kind_label",
|
|
@@ -343,8 +359,12 @@
|
|
|
343
359
|
},
|
|
344
360
|
"columns": [
|
|
345
361
|
{
|
|
346
|
-
"field": "
|
|
347
|
-
"
|
|
362
|
+
"field": "subject_title",
|
|
363
|
+
"template": "{{subject_title}}",
|
|
364
|
+
"header": "Item",
|
|
365
|
+
"subtitleField": "subject_key",
|
|
366
|
+
"truncate": true,
|
|
367
|
+
"linkField": "subject_url"
|
|
348
368
|
},
|
|
349
369
|
{
|
|
350
370
|
"field": "question",
|