@nanobpm/nano-workforce 0.37.0 → 0.38.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/README.md +5 -6
- package/SPEC.md +15 -13
- package/app/abandon.test.ts +4 -4
- package/app/abandon.ts +2 -2
- package/app/blackboard.test.ts +3 -3
- package/app/blackboard.ts +2 -2
- package/app/persist-escalation.test.ts +1 -1
- package/app/persist-round.test.ts +1 -1
- package/app/service.ts +1 -1
- package/biome.json +0 -2
- package/main.ts +3 -3
- package/nano.app.json +2 -25
- package/openapi.yaml +649 -0
- package/operations/answerFeatureEscalation.test.ts +100 -0
- package/operations/answerFeatureEscalation.ts +49 -0
- package/operations/appendBlackboard.ts +61 -0
- package/{actions → operations}/blackboard.test.ts +16 -10
- package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +5 -11
- package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
- package/operations/listActivePrs.ts +1 -1
- package/operations/postMessage.ts +1 -1
- package/operations/readBlackboard.ts +29 -0
- package/operations/startAndMessage.test.ts +5 -3
- package/operations/startConvergenceLoop.ts +17 -11
- package/operations/startPlanFanout.ts +13 -7
- package/package.json +4 -4
- package/pages/epic.page.json +1 -1
- package/pages/home.page.json +1 -1
- package/workers/persist-task-escalation/worker.ts +1 -1
- package/actions/blackboard.ts +0 -77
- package/actions/feature-answer-hook.ts +0 -45
- package/actions/plan-hook.ts +0 -20
- package/actions/webhook-submit.ts +0 -22
- package/openapi.json +0 -248
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// POST /hooks/feature-answer — answer an implementation-phase task escalation out
|
|
2
|
-
// of band (optional shared-secret guard via X-Hook-Secret, enforced only when
|
|
3
|
-
// NANO_PR_WEBHOOK_SECRET is set — mirrors /hooks/submit and /hooks/plan), issue #25.
|
|
4
|
-
// Lets an external
|
|
5
|
-
// system (a chat relay, a CI job, a human via curl) resume a parked implementation
|
|
6
|
-
// agent without the page. Same idempotent `answerTaskEscalation` path the page's
|
|
7
|
-
// answer form uses.
|
|
8
|
-
//
|
|
9
|
-
// Body accepts either the raw correlation key or a plan+task pair:
|
|
10
|
-
// { "corrKey": "owner/repo#12:task-3", "answer": "…" }
|
|
11
|
-
// { "plan": "owner/repo#12", "task": "task-3", "answer": "…" }
|
|
12
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
13
|
-
import { answerTaskEscalation, featureCorrKey } from "../app/plan.ts";
|
|
14
|
-
|
|
15
|
-
const WEBHOOK_SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
|
|
16
|
-
|
|
17
|
-
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
18
|
-
|
|
19
|
-
const handler: ActionHandler = async ({ req, body }, app) => {
|
|
20
|
-
if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
|
|
21
|
-
return { status: 401, body: { error: "unauthorized" } };
|
|
22
|
-
}
|
|
23
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
24
|
-
const b = (body ?? {}) as {
|
|
25
|
-
corrKey?: unknown;
|
|
26
|
-
plan?: unknown;
|
|
27
|
-
task?: unknown;
|
|
28
|
-
answer?: unknown;
|
|
29
|
-
};
|
|
30
|
-
const answer = str(b.answer);
|
|
31
|
-
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
32
|
-
|
|
33
|
-
const corrKey = str(b.corrKey) || (str(b.plan) && str(b.task) ? featureCorrKey(str(b.plan), str(b.task)) : "");
|
|
34
|
-
if (!corrKey) {
|
|
35
|
-
return {
|
|
36
|
-
status: 400,
|
|
37
|
-
body: { error: "provide corrKey, or both plan (owner/repo#N) and task" },
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
|
|
42
|
-
return { status: r.ok ? 200 : 404, body: r };
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export default handler;
|
package/actions/plan-hook.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// POST /hooks/plan — kick off a planning fan-out out of band (shared-secret auth via
|
|
2
|
-
// X-Hook-Secret). Lets an external system (a GitHub webhook relay on issue open/label, a CI job)
|
|
3
|
-
// hand an issue to the fleet. Same idempotent startPlan path as the page's "Plan issue" action.
|
|
4
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
5
|
-
import { parseIssue, startPlan } from "../app/plan.ts";
|
|
6
|
-
|
|
7
|
-
const WEBHOOK_SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
|
|
8
|
-
|
|
9
|
-
const handler: ActionHandler = async ({ req, body }, app) => {
|
|
10
|
-
if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
|
|
11
|
-
return { status: 401, body: { error: "unauthorized" } };
|
|
12
|
-
}
|
|
13
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
14
|
-
const b = (body ?? {}) as { url?: unknown; issue?: unknown };
|
|
15
|
-
const parsed = parseIssue(String(b.issue ?? b.url ?? ""));
|
|
16
|
-
if (!parsed) return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
|
|
17
|
-
return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export default handler;
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// POST /hooks/submit — submit a PR out-of-band (shared-secret auth via X-Hook-Secret). Not
|
|
2
|
-
// part of the page UI; lets an external system (a GitHub webhook relay, a CI job) kick off a
|
|
3
|
-
// convergence run. Same idempotent submit path as the page's "Start review" action.
|
|
4
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
5
|
-
import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
|
|
6
|
-
|
|
7
|
-
const WEBHOOK_SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
|
|
8
|
-
|
|
9
|
-
const handler: ActionHandler = async ({ req, body }, app) => {
|
|
10
|
-
if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
|
|
11
|
-
return { status: 401, body: { error: "unauthorized" } };
|
|
12
|
-
}
|
|
13
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
14
|
-
const b = (body ?? {}) as { url?: unknown; pr?: unknown; dependsOn?: unknown; maxRounds?: unknown };
|
|
15
|
-
const parsed = parsePr(String(b.url ?? b.pr ?? ""));
|
|
16
|
-
if (!parsed) return { status: 400, body: { error: "could not parse PR url" } };
|
|
17
|
-
const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
|
|
18
|
-
const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
|
|
19
|
-
return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export default handler;
|
package/openapi.json
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"openapi": "3.0.3",
|
|
3
|
-
"info": {
|
|
4
|
-
"title": "Nano Workforce control API",
|
|
5
|
-
"version": "1.0.0",
|
|
6
|
-
"description": "The externally-facing control surface an operator, an automation harness, or an LLM uses to observe and steer PR-convergence and planning runs. Contract-first (ADR 0058): the toolkit derives typed request/response contracts + runtime validators from this document and each `operationId` is implemented by a delegate module in `operations/`. Mounted under base `/app/api`, kept off the framework-reserved `/app` page-runtime namespace (which owns `/app/runtime.js`, `/app/pages/*`, `/app/data/*`). The `/hooks/*` webhook endpoints stay on `actions[]` because they live outside the `/app` namespace."
|
|
7
|
-
},
|
|
8
|
-
"components": {
|
|
9
|
-
"securitySchemes": {
|
|
10
|
-
"hookSecret": {
|
|
11
|
-
"type": "apiKey",
|
|
12
|
-
"in": "header",
|
|
13
|
-
"name": "x-hook-secret",
|
|
14
|
-
"description": "Optional shared secret. Enforced by the delegate (NOT the runtime) only when NANO_PR_WEBHOOK_SECRET is set; unset means the endpoint is open. Declared here for documentation."
|
|
15
|
-
}
|
|
16
|
-
},
|
|
17
|
-
"schemas": {
|
|
18
|
-
"ErrorBody": {
|
|
19
|
-
"type": "object",
|
|
20
|
-
"required": ["error"],
|
|
21
|
-
"properties": { "error": { "type": "string" } }
|
|
22
|
-
},
|
|
23
|
-
"ActivePr": {
|
|
24
|
-
"type": "object",
|
|
25
|
-
"description": "A tracked PR that is not in a terminal (converged/abandoned) state.",
|
|
26
|
-
"required": [
|
|
27
|
-
"prKey", "repo", "number", "url", "title", "status", "round",
|
|
28
|
-
"processKey", "waitingSince", "openEscalation", "updatedAt",
|
|
29
|
-
"activeWorker", "leaseUntil"
|
|
30
|
-
],
|
|
31
|
-
"properties": {
|
|
32
|
-
"prKey": { "type": "string" },
|
|
33
|
-
"repo": { "type": "string" },
|
|
34
|
-
"number": { "type": "integer" },
|
|
35
|
-
"url": { "type": "string" },
|
|
36
|
-
"title": { "type": ["string", "null"] },
|
|
37
|
-
"status": { "type": "string" },
|
|
38
|
-
"round": { "type": "integer" },
|
|
39
|
-
"processKey": { "type": ["string", "null"], "description": "The engine process instance key; also the keyField the pages processExplorer link uses." },
|
|
40
|
-
"waitingSince": { "type": ["string", "null"] },
|
|
41
|
-
"openEscalation": { "type": ["string", "null"] },
|
|
42
|
-
"updatedAt": { "type": "string" },
|
|
43
|
-
"activeWorker": { "type": ["string", "null"] },
|
|
44
|
-
"leaseUntil": { "type": ["string", "null"] }
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
"ActivePrList": {
|
|
48
|
-
"type": "object",
|
|
49
|
-
"required": ["count", "prs"],
|
|
50
|
-
"properties": {
|
|
51
|
-
"count": { "type": "integer" },
|
|
52
|
-
"prs": { "type": "array", "items": { "$ref": "#/components/schemas/ActivePr" } }
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
"VersionInfo": {
|
|
56
|
-
"type": "object",
|
|
57
|
-
"description": "The running app's identity (which code is actually live).",
|
|
58
|
-
"required": [
|
|
59
|
-
"name", "version", "urbanVersion", "gitSha", "gitBranch",
|
|
60
|
-
"runtime", "pid", "startedAt", "uptimeSeconds"
|
|
61
|
-
],
|
|
62
|
-
"properties": {
|
|
63
|
-
"name": { "type": "string" },
|
|
64
|
-
"version": { "type": ["string", "null"] },
|
|
65
|
-
"urbanVersion": { "type": ["string", "null"] },
|
|
66
|
-
"gitSha": { "type": ["string", "null"] },
|
|
67
|
-
"gitBranch": { "type": ["string", "null"] },
|
|
68
|
-
"runtime": { "type": "string" },
|
|
69
|
-
"pid": { "type": ["integer", "null"] },
|
|
70
|
-
"startedAt": { "type": "string" },
|
|
71
|
-
"uptimeSeconds": { "type": "integer" }
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
"SubmitResult": {
|
|
75
|
-
"type": "object",
|
|
76
|
-
"required": ["prKey"],
|
|
77
|
-
"properties": {
|
|
78
|
-
"prKey": { "type": "string" },
|
|
79
|
-
"processKey": { "type": ["string", "null"], "description": "The started convergence-loop instance key (null if the engine did not return one)." },
|
|
80
|
-
"alreadyRunning": { "type": "boolean", "description": "True when a non-terminal convergence loop for this PR already exists; the aggregate was refreshed and no new instance was started." }
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
"StartPlanResult": {
|
|
84
|
-
"type": "object",
|
|
85
|
-
"required": ["planKey"],
|
|
86
|
-
"properties": {
|
|
87
|
-
"planKey": { "type": "string" },
|
|
88
|
-
"processKey": { "type": ["string", "null"] },
|
|
89
|
-
"alreadyRunning": { "type": "boolean", "description": "True when a non-terminal plan for this issue already exists; no new instance was started." }
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
"StartVariables": {
|
|
93
|
-
"type": "object",
|
|
94
|
-
"description": "Process-start variables. `pr`/`url` (convergence) or `issue`/`url` (planning) name the target; extra keys are forwarded to the engine.",
|
|
95
|
-
"additionalProperties": true,
|
|
96
|
-
"properties": {
|
|
97
|
-
"pr": { "type": "string", "description": "PR reference: owner/repo#123 or a PR URL." },
|
|
98
|
-
"issue": { "type": "string", "description": "Issue reference: owner/repo#123 or an issue URL." },
|
|
99
|
-
"url": { "type": "string", "description": "Alias for pr/issue when a bare URL is supplied." },
|
|
100
|
-
"dependsOn": { "type": "array", "items": { "type": "string" } },
|
|
101
|
-
"maxRounds": { "type": "integer", "minimum": 1 }
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
"MessageResult": {
|
|
105
|
-
"type": "object",
|
|
106
|
-
"description": "The result of publishing a message / answering an escalation. Shape varies by message name; `ok` is always present.",
|
|
107
|
-
"additionalProperties": true,
|
|
108
|
-
"required": ["ok"],
|
|
109
|
-
"properties": { "ok": { "type": "boolean" } }
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
"paths": {
|
|
114
|
-
"/status": {
|
|
115
|
-
"get": {
|
|
116
|
-
"operationId": "listActivePrs",
|
|
117
|
-
"summary": "List every tracked PR currently in flight (not converged/abandoned), newest-updated first.",
|
|
118
|
-
"security": [{ "hookSecret": [] }, {}],
|
|
119
|
-
"responses": {
|
|
120
|
-
"200": {
|
|
121
|
-
"description": "The active PRs.",
|
|
122
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivePrList" } } }
|
|
123
|
-
},
|
|
124
|
-
"401": {
|
|
125
|
-
"description": "Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).",
|
|
126
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
},
|
|
131
|
-
"/version": {
|
|
132
|
-
"get": {
|
|
133
|
-
"operationId": "getVersion",
|
|
134
|
-
"summary": "The running app's identity (app/urban versions, git sha/branch, runtime, pid, uptime).",
|
|
135
|
-
"security": [{ "hookSecret": [] }, {}],
|
|
136
|
-
"responses": {
|
|
137
|
-
"200": {
|
|
138
|
-
"description": "The version/identity payload.",
|
|
139
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/VersionInfo" } } }
|
|
140
|
-
},
|
|
141
|
-
"401": {
|
|
142
|
-
"description": "Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).",
|
|
143
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
"/actions/start/convergence-loop": {
|
|
149
|
-
"post": {
|
|
150
|
-
"operationId": "startConvergenceLoop",
|
|
151
|
-
"summary": "Register/refresh a PR aggregate (idempotent on prKey) and start its convergence loop.",
|
|
152
|
-
"requestBody": {
|
|
153
|
-
"required": true,
|
|
154
|
-
"content": {
|
|
155
|
-
"application/json": {
|
|
156
|
-
"schema": {
|
|
157
|
-
"type": "object",
|
|
158
|
-
"additionalProperties": true,
|
|
159
|
-
"required": ["variables"],
|
|
160
|
-
"properties": { "variables": { "$ref": "#/components/schemas/StartVariables" } }
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
"responses": {
|
|
166
|
-
"202": {
|
|
167
|
-
"description": "The loop was started (or refreshed).",
|
|
168
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitResult" } } }
|
|
169
|
-
},
|
|
170
|
-
"400": {
|
|
171
|
-
"description": "The PR reference could not be parsed.",
|
|
172
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
"/actions/start/plan-fanout": {
|
|
178
|
-
"post": {
|
|
179
|
-
"operationId": "startPlanFanout",
|
|
180
|
-
"summary": "Register/refresh a plan aggregate (idempotent on planKey) and start the planning fan-out.",
|
|
181
|
-
"requestBody": {
|
|
182
|
-
"required": true,
|
|
183
|
-
"content": {
|
|
184
|
-
"application/json": {
|
|
185
|
-
"schema": {
|
|
186
|
-
"type": "object",
|
|
187
|
-
"additionalProperties": true,
|
|
188
|
-
"required": ["variables"],
|
|
189
|
-
"properties": { "variables": { "$ref": "#/components/schemas/StartVariables" } }
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
},
|
|
194
|
-
"responses": {
|
|
195
|
-
"202": {
|
|
196
|
-
"description": "The plan fan-out was started (or was already running).",
|
|
197
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/StartPlanResult" } } }
|
|
198
|
-
},
|
|
199
|
-
"400": {
|
|
200
|
-
"description": "The issue reference could not be parsed.",
|
|
201
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
},
|
|
206
|
-
"/actions/message": {
|
|
207
|
-
"post": {
|
|
208
|
-
"operationId": "postMessage",
|
|
209
|
-
"summary": "Publish a message / answer an escalation. For escalation-answered and feature-escalation-answered names, runs the corresponding answer flow; otherwise a plain publishMessage.",
|
|
210
|
-
"requestBody": {
|
|
211
|
-
"required": true,
|
|
212
|
-
"content": {
|
|
213
|
-
"application/json": {
|
|
214
|
-
"schema": {
|
|
215
|
-
"type": "object",
|
|
216
|
-
"additionalProperties": true,
|
|
217
|
-
"required": ["name"],
|
|
218
|
-
"properties": {
|
|
219
|
-
"name": { "type": "string", "description": "The message name (correlates a waiting event)." },
|
|
220
|
-
"correlationKey": { "type": "string" },
|
|
221
|
-
"variables": {
|
|
222
|
-
"type": "object",
|
|
223
|
-
"additionalProperties": true,
|
|
224
|
-
"properties": { "answer": { "type": "string" } }
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
},
|
|
231
|
-
"responses": {
|
|
232
|
-
"200": {
|
|
233
|
-
"description": "The message was published (or the escalation answered).",
|
|
234
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/MessageResult" } } }
|
|
235
|
-
},
|
|
236
|
-
"400": {
|
|
237
|
-
"description": "A required field was missing/blank.",
|
|
238
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
239
|
-
},
|
|
240
|
-
"404": {
|
|
241
|
-
"description": "No matching open escalation / parked token to answer.",
|
|
242
|
-
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/MessageResult" } } }
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|