@nanobpm/nano-workforce 0.79.0 → 0.81.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/openapi.yaml CHANGED
@@ -99,6 +99,99 @@ components:
99
99
  type: array
100
100
  items:
101
101
  $ref: "#/components/schemas/ActivePr"
102
+ LineagePrView:
103
+ type: object
104
+ description: A member PR of a lineage thread (issue #245).
105
+ additionalProperties: false
106
+ required:
107
+ - prKey
108
+ - title
109
+ - url
110
+ - status
111
+ - round
112
+ - processKey
113
+ - outcome
114
+ properties:
115
+ prKey:
116
+ type: string
117
+ title:
118
+ type: string
119
+ nullable: true
120
+ url:
121
+ type: string
122
+ status:
123
+ type: string
124
+ round:
125
+ type: integer
126
+ processKey:
127
+ type: string
128
+ nullable: true
129
+ outcome:
130
+ type: string
131
+ nullable: true
132
+ LineageThreadView:
133
+ type: object
134
+ description: One stitched intent → progress arc, keyed by its origin request (issue #245).
135
+ additionalProperties: false
136
+ required:
137
+ - rootRequestKey
138
+ - kind
139
+ - title
140
+ - issueUrl
141
+ - stage
142
+ - stageLabel
143
+ - processKey
144
+ - prKeys
145
+ - prCount
146
+ - active
147
+ - prs
148
+ properties:
149
+ rootRequestKey:
150
+ type: string
151
+ description: The origin issue key (feature_key/plan_key), or a self-rooted pr_key.
152
+ kind:
153
+ type: string
154
+ enum: [feature, epic, pr]
155
+ title:
156
+ type: string
157
+ nullable: true
158
+ issueUrl:
159
+ type: string
160
+ nullable: true
161
+ stage:
162
+ type: string
163
+ description: The active-frontier stage (implementing|converging|merged|…).
164
+ stageLabel:
165
+ type: string
166
+ processKey:
167
+ type: string
168
+ nullable: true
169
+ description: The active-frontier process instance key (for the processExplorer link).
170
+ prKeys:
171
+ type: array
172
+ items:
173
+ type: string
174
+ prCount:
175
+ type: integer
176
+ active:
177
+ type: boolean
178
+ description: True while the arc has an active frontier; false once every stage has settled.
179
+ prs:
180
+ type: array
181
+ items:
182
+ $ref: "#/components/schemas/LineagePrView"
183
+ LineageList:
184
+ type: object
185
+ required:
186
+ - count
187
+ - threads
188
+ properties:
189
+ count:
190
+ type: integer
191
+ threads:
192
+ type: array
193
+ items:
194
+ $ref: "#/components/schemas/LineageThreadView"
102
195
  AgenticSupplyWorker:
103
196
  type: object
104
197
  description: One connected worker in the supply mirror (H5 cockpit; sourced from the H1 presence registry).
@@ -936,6 +1029,33 @@ paths:
936
1029
  application/json:
937
1030
  schema:
938
1031
  $ref: "#/components/schemas/ErrorBody"
1032
+ /lineage:
1033
+ get:
1034
+ operationId: getLineage
1035
+ summary: "Intent to progress lineage projection (issue #245): every request stitched into one arc (request, implementation, PRs, convergence, merge, outcome), active frontier first. Pass root to fetch a single origin's thread."
1036
+ security:
1037
+ - hookSecret: []
1038
+ - {}
1039
+ parameters:
1040
+ - name: root
1041
+ in: query
1042
+ required: false
1043
+ schema:
1044
+ type: string
1045
+ description: "An origin request key (feature_key/plan_key) or a self-rooted pr_key. When set, the response contains just that thread (empty if unknown)."
1046
+ responses:
1047
+ "200":
1048
+ description: The stitched lineage threads.
1049
+ content:
1050
+ application/json:
1051
+ schema:
1052
+ $ref: "#/components/schemas/LineageList"
1053
+ "401":
1054
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
1055
+ content:
1056
+ application/json:
1057
+ schema:
1058
+ $ref: "#/components/schemas/ErrorBody"
939
1059
  /agentic/supply:
940
1060
  get:
941
1061
  operationId: getAgenticSupply
@@ -0,0 +1,105 @@
1
+ // Tests for GET /app/api/lineage → operation `getLineage` (issue #245). Covers the stitched list
2
+ // projection, the `root` narrowing, an unknown root (empty), and the optional shared-secret guard.
3
+ // A minimal in-memory DataLayer backs the derivation (it reads the feature_runs / plans / plan_tasks
4
+ // / pull_requests tables via `.all()` / `.find()`).
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import type { AppApi } from "@nanobpm/urban";
8
+ import { noopLog } from "../test/log.ts";
9
+ import handler from "./getLineage.ts";
10
+
11
+ function memApp(stores: Record<string, any[]>): AppApi {
12
+ const table = (name: string) => {
13
+ const rows = stores[name] ?? [];
14
+ return {
15
+ async all() {
16
+ return rows.slice();
17
+ },
18
+ async find(where: Record<string, unknown> = {}) {
19
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
20
+ },
21
+ };
22
+ };
23
+ return { data: { table }, log: noopLog() } as any as AppApi;
24
+ }
25
+
26
+ function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
27
+ return {
28
+ req: {
29
+ method: "GET",
30
+ path: "/app/api/lineage",
31
+ query: new URLSearchParams(query),
32
+ headers: new Headers(headers),
33
+ text: async () => "",
34
+ } as any,
35
+ params: {},
36
+ query,
37
+ body: undefined,
38
+ };
39
+ }
40
+
41
+ function fixture(): Record<string, any[]> {
42
+ return {
43
+ feature_runs: [
44
+ { feature_key: "o/r#1", title: "Feature", issue_url: "u1", status: "converging", process_key: "f1", pr_key: "o/r#100" },
45
+ ],
46
+ plans: [
47
+ { plan_key: "o/r#2", title: "Epic", issue_url: "u2", status: "done", process_key: "e1" },
48
+ ],
49
+ plan_tasks: [
50
+ { id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
51
+ { id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
52
+ ],
53
+ pull_requests: [
54
+ { pr_key: "o/r#100", title: "Feat PR", url: "x", status: "converging", current_round: 2, process_key: "c1", outcome: null, root_request_key: "o/r#1" },
55
+ { pr_key: "o/r#200", title: "S1", url: "x", status: "merged", current_round: 1, process_key: "c2", outcome: null, root_request_key: "o/r#2" },
56
+ { pr_key: "o/r#201", title: "S2", url: "x", status: "converging", current_round: 1, process_key: "c3", outcome: null, root_request_key: "o/r#2" },
57
+ { pr_key: "o/r#300", title: "Human PR", url: "x", status: "merged", current_round: 1, process_key: "c4", outcome: null, root_request_key: null },
58
+ ],
59
+ };
60
+ }
61
+
62
+ test("lists every stitched thread, active frontier first", async () => {
63
+ const res = (await handler(input(), memApp(fixture()))) as any;
64
+ assertEquals(res.status, 200);
65
+ assertEquals(res.body.count, 3);
66
+ // Active threads (feature + epic) sort ahead of the settled human PR.
67
+ assert(res.body.threads[res.body.threads.length - 1].active === false);
68
+ const kinds = res.body.threads.map((t: any) => t.kind).sort();
69
+ assertEquals(kinds, ["epic", "feature", "pr"]);
70
+ });
71
+
72
+ test("narrows to a single origin thread via ?root=", async () => {
73
+ const res = (await handler(input({ root: "o/r#2" }), memApp(fixture()))) as any;
74
+ assertEquals(res.status, 200);
75
+ assertEquals(res.body.count, 1);
76
+ const t = res.body.threads[0];
77
+ assertEquals(t.kind, "epic");
78
+ assertEquals(t.rootRequestKey, "o/r#2");
79
+ assertEquals(t.prCount, 2);
80
+ });
81
+
82
+ test("unknown root returns an empty thread list", async () => {
83
+ const res = (await handler(input({ root: "o/r#999" }), memApp(fixture()))) as any;
84
+ assertEquals(res.status, 200);
85
+ assertEquals(res.body.count, 0);
86
+ assertEquals(res.body.threads, []);
87
+ });
88
+
89
+ test("shared-secret guard rejects a missing secret when configured", async () => {
90
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
91
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
92
+ try {
93
+ const mod = await import(`./getLineage.ts?guard=${Date.now()}`);
94
+ const guarded = mod.default as typeof handler;
95
+ const app = memApp(fixture());
96
+ const bad = (await guarded(input(), app)) as any;
97
+ assertEquals(bad.status, 401);
98
+ const ok = (await guarded(input({}, { "x-hook-secret": "s3cr3t" }), app)) as any;
99
+ assertEquals(ok.status, 200);
100
+ assert("count" in ok.body);
101
+ } finally {
102
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
103
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
104
+ }
105
+ });
@@ -0,0 +1,32 @@
1
+ // GET /app/api/lineage → operationId `getLineage` (issue #245). Project user intent → progress as
2
+ // one lineage arc per origin request, so an operator or an external harness can see, in one place,
3
+ // that "issue #123 → implementation → PR #45 → converging (round 2) → merged" is a single thread of
4
+ // their intent — without opening the DB or joining the tables by hand. Read-only projection over the
5
+ // existing record-gateway joins (the same derivation the `lineage_threads` read table is built from).
6
+ //
7
+ // Optional `root` query param narrows to a single origin's thread (feature_key/plan_key, or a
8
+ // self-rooted pr_key for a human/webhook PR). Absent → every thread, active frontier first.
9
+ //
10
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`):
11
+ // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header.
12
+ import { getLineage, listLineage } from "../app/lineage.ts";
13
+ import { envVar } from "../app/version.ts";
14
+ import { defineOperation } from "../nano-generated/operations.ts";
15
+
16
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
17
+
18
+ export default defineOperation("getLineage", async ({ query, req }, app) => {
19
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
20
+ app.log.warn("getLineage rejected: missing/invalid shared secret");
21
+ return { status: 401, body: { error: "unauthorized" } };
22
+ }
23
+ const rawRoot = query.root;
24
+ const root = typeof rawRoot === "string" ? rawRoot.trim() : "";
25
+ if (root) {
26
+ const thread = await getLineage(app.data, root);
27
+ const threads = thread ? [thread] : [];
28
+ return { status: 200, body: { count: threads.length, threads } };
29
+ }
30
+ const threads = await listLineage(app.data);
31
+ return { status: 200, body: { count: threads.length, threads } };
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.79.0",
3
+ "version": "0.81.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",
@@ -10,6 +10,7 @@
10
10
  "title": "Nano Workforce",
11
11
  "items": [
12
12
  { "label": "Overview", "page": "overview" },
13
+ { "label": "Lineage", "page": "lineage" },
13
14
  { "label": "Convergence", "page": "home" },
14
15
  { "label": "Epics", "page": "epic" },
15
16
  { "label": "Feature", "page": "feature" },
@@ -11,6 +11,7 @@
11
11
  "title": "Nano Workforce",
12
12
  "items": [
13
13
  { "label": "Overview", "page": "overview" },
14
+ { "label": "Lineage", "page": "lineage" },
14
15
  { "label": "Convergence", "page": "home" },
15
16
  { "label": "Epics", "page": "epic" },
16
17
  { "label": "Feature", "page": "feature" },
@@ -11,6 +11,7 @@
11
11
  "title": "Nano Workforce",
12
12
  "items": [
13
13
  { "label": "Overview", "page": "overview" },
14
+ { "label": "Lineage", "page": "lineage" },
14
15
  { "label": "Convergence", "page": "home" },
15
16
  { "label": "Epics", "page": "epic" },
16
17
  { "label": "Feature", "page": "feature" },
@@ -11,6 +11,7 @@
11
11
  "title": "Nano Workforce",
12
12
  "items": [
13
13
  { "label": "Overview", "page": "overview" },
14
+ { "label": "Lineage", "page": "lineage" },
14
15
  { "label": "Convergence", "page": "home" },
15
16
  { "label": "Epics", "page": "epic" },
16
17
  { "label": "Feature", "page": "feature" },
@@ -13,6 +13,10 @@
13
13
  "label": "Overview",
14
14
  "page": "overview"
15
15
  },
16
+ {
17
+ "label": "Lineage",
18
+ "page": "lineage"
19
+ },
16
20
  {
17
21
  "label": "Convergence",
18
22
  "page": "home"
@@ -0,0 +1,146 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "title": "Lineage",
4
+ "nodes": [
5
+ {
6
+ "type": "nav",
7
+ "id": "nav",
8
+ "props": {
9
+ "variant": "bar",
10
+ "title": "Nano Workforce",
11
+ "items": [
12
+ { "label": "Overview", "page": "overview" },
13
+ { "label": "Lineage", "page": "lineage" },
14
+ { "label": "Convergence", "page": "home" },
15
+ { "label": "Epics", "page": "epic" },
16
+ { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
18
+ { "label": "Cockpit", "page": "cockpit" }
19
+ ],
20
+ "sticky": true
21
+ }
22
+ },
23
+ {
24
+ "type": "text",
25
+ "id": "title",
26
+ "props": { "text": "Lineage \u2014 intent \u2192 progress", "variant": "heading" }
27
+ },
28
+ {
29
+ "type": "text",
30
+ "id": "subtitle",
31
+ "props": {
32
+ "text": "One narrative per request, not a card-swap. Each row is a single arc of your intent \u2014 request \u2192 implementing \u2192 PR opened \u2192 converging (round n) \u2192 merged/converged/abandoned \u2014 stitched from the feature/epic that started it, the PR(s) it produced, and their convergence + merge outcome. The Stage column is the active frontier; drill into a row for its member PR(s) and rounds. Epics fan out to N PR sub-threads. A human/webhook PR with no originating request is shown as its own root.",
33
+ "variant": "sub"
34
+ }
35
+ },
36
+ {
37
+ "type": "dataGrid",
38
+ "id": "threads",
39
+ "props": {
40
+ "title": "Lineage threads",
41
+ "collapsible": true,
42
+ "defaultCollapsed": false,
43
+ "showCount": true,
44
+ "rowKey": "root_request_key",
45
+ "refreshMs": 5000,
46
+ "data": {
47
+ "kind": "datasource",
48
+ "source": "app",
49
+ "table": "lineage_threads",
50
+ "orderBy": { "field": "updated_at", "dir": "desc" },
51
+ "filter": [{ "field": "active", "in": [1] }]
52
+ },
53
+ "tabs": [
54
+ {
55
+ "label": "Active",
56
+ "filter": [{ "field": "active", "in": [1] }]
57
+ },
58
+ {
59
+ "label": "History",
60
+ "filter": [{ "field": "active", "in": [0] }]
61
+ }
62
+ ],
63
+ "columns": [
64
+ {
65
+ "field": "title",
66
+ "template": "{{title}}",
67
+ "header": "Intent",
68
+ "subtitleField": "root_request_key",
69
+ "truncate": true,
70
+ "width": "34%",
71
+ "linkField": "issue_url"
72
+ },
73
+ { "field": "kind", "header": "Kind", "width": "6rem" },
74
+ {
75
+ "field": "stage_label",
76
+ "header": "Stage",
77
+ "truncate": true,
78
+ "link": { "kind": "processExplorer", "keyField": "process_key" }
79
+ },
80
+ { "field": "pr_count", "header": "PRs", "width": "5rem" },
81
+ { "field": "updated_at", "header": "Updated", "width": "9rem" }
82
+ ],
83
+ "detail": {
84
+ "linkField": "issue_url",
85
+ "fields": [
86
+ { "field": "kind", "label": "Origin kind" },
87
+ { "field": "stage", "label": "Frontier stage" },
88
+ { "field": "stage_label", "label": "Frontier" },
89
+ { "field": "issue_url", "label": "Origin issue" },
90
+ { "field": "pr_count", "label": "PR count" }
91
+ ],
92
+ "children": [
93
+ {
94
+ "title": "Pull requests in this thread",
95
+ "source": "app",
96
+ "table": "pull_requests",
97
+ "parentField": "root_request_key",
98
+ "childField": "root_request_key",
99
+ "orderBy": { "field": "updated_at", "dir": "asc" },
100
+ "columns": [
101
+ { "field": "title", "template": "{{title}}", "header": "PR", "subtitleField": "pr_key", "truncate": true, "linkField": "url" },
102
+ { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
103
+ { "field": "current_round", "header": "Round" },
104
+ { "field": "outcome", "header": "Outcome", "truncate": true },
105
+ { "field": "updated_at", "header": "Updated", "width": "9rem" }
106
+ ],
107
+ "detail": {
108
+ "linkField": "url",
109
+ "children": [
110
+ {
111
+ "title": "Rounds",
112
+ "source": "app",
113
+ "table": "rounds",
114
+ "parentField": "pr_key",
115
+ "childField": "pr_key",
116
+ "orderBy": { "field": "round_no", "dir": "asc" },
117
+ "columns": [
118
+ { "field": "round_no", "header": "#" },
119
+ { "field": "status", "header": "Result" },
120
+ { "field": "summary", "header": "Summary" }
121
+ ]
122
+ },
123
+ {
124
+ "title": "Escalations",
125
+ "source": "app",
126
+ "table": "escalations",
127
+ "parentField": "pr_key",
128
+ "childField": "pr_key",
129
+ "orderBy": { "field": "id", "dir": "asc" },
130
+ "columns": [
131
+ { "field": "round_no", "header": "#" },
132
+ { "field": "kind", "header": "Kind" },
133
+ { "field": "question", "header": "Question" },
134
+ { "field": "status", "header": "Status" },
135
+ { "field": "answer", "header": "Answer" }
136
+ ]
137
+ }
138
+ ]
139
+ }
140
+ }
141
+ ]
142
+ }
143
+ }
144
+ }
145
+ ]
146
+ }
@@ -11,6 +11,7 @@
11
11
  "title": "Nano Workforce",
12
12
  "items": [
13
13
  { "label": "Overview", "page": "overview" },
14
+ { "label": "Lineage", "page": "lineage" },
14
15
  { "label": "Convergence", "page": "home" },
15
16
  { "label": "Epics", "page": "epic" },
16
17
  { "label": "Feature", "page": "feature" },
@@ -13,6 +13,10 @@
13
13
  "label": "Overview",
14
14
  "page": "overview"
15
15
  },
16
+ {
17
+ "label": "Lineage",
18
+ "page": "lineage"
19
+ },
16
20
  {
17
21
  "label": "Convergence",
18
22
  "page": "home"
@@ -0,0 +1,29 @@
1
+ {
2
+ "id": "readiness-escalation",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "text",
8
+ "text": "The artifact-readiness wait-gate timed out before its ReadinessProbe went green. A human decision is needed on how to proceed.",
9
+ "label": "Readiness timeout"
10
+ },
11
+ {
12
+ "type": "select",
13
+ "key": "resolution",
14
+ "label": "Resolution",
15
+ "values": [
16
+ { "label": "Acknowledge — the artifact is ready / proceed", "value": "acknowledge" },
17
+ { "label": "Abandon — give up on this gate", "value": "abandon" }
18
+ ],
19
+ "validate": {
20
+ "required": true
21
+ }
22
+ },
23
+ {
24
+ "type": "textarea",
25
+ "key": "answer",
26
+ "label": "Notes for the operator"
27
+ }
28
+ ]
29
+ }