@nanobpm/nano-workforce 0.194.0 → 0.195.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 +6 -0
- package/README.md +6 -0
- package/SPEC.md +19 -1
- package/app/agentCompletion.ts +5 -1
- package/app/convergeGate.test.ts +9 -2
- package/app/convergenceStaleRoundReset.test.ts +1 -1
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +8 -0
- package/app/featureDelivery.test.ts +21 -0
- package/app/fineGrainedCells.test.ts +1 -1
- package/app/mcpToolSurface.ts +6 -1
- package/app/mergeApproval.test.ts +126 -0
- package/app/service.test.ts +105 -0
- package/app/service.ts +43 -4
- package/app/userTasks.ts +7 -0
- package/db/migrations/116_feature_runs_human_approval.sql +51 -0
- package/e2e/feature-run.e2e.ts +18 -0
- package/openapi.yaml +15 -0
- package/operations/startFeature.readiness.integration.test.ts +14 -0
- package/operations/startFeature.ts +4 -0
- package/package.json +1 -1
- package/pages/feature.page.json +1 -0
- package/resources/forms/merge-approval.form +36 -0
- package/resources/processes/convergence-loop.bpmn +216 -132
- package/resources/processes/feature.bpmn +2 -0
- package/resources/prompts/review-round.md +4 -3
- package/test/derivation-parity/README.md +4 -3
- package/test/derivation-parity/derivation-parity.test.ts +7 -7
- package/test/derivation-parity/flows.ts +6 -5
- package/workers/converge-feature/worker.ts +3 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Issue #826: optional human approval before merge. 1 ⇒ the converged PR parks at the
|
|
2
|
+
-- `merge-approval` user task in convergence-loop.bpmn before the merge-loop runs. Additive (expand).
|
|
3
|
+
ALTER TABLE feature_runs ADD COLUMN human_approval INTEGER NOT NULL DEFAULT 0;
|
|
4
|
+
|
|
5
|
+
-- Mirror the new column through the `delivery_units` aggregate so the `feature_runs__units` compat VIEW
|
|
6
|
+
-- stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in app/deliveryUnit.test.ts): the
|
|
7
|
+
-- Feature sync triggers (089) and VIEW (091) are re-created verbatim plus `human_approval`.
|
|
8
|
+
ALTER TABLE delivery_units ADD COLUMN human_approval INTEGER;
|
|
9
|
+
UPDATE delivery_units SET human_approval = 0 WHERE kind = 'feature';
|
|
10
|
+
|
|
11
|
+
DROP TRIGGER IF EXISTS feature_runs__du_ai;
|
|
12
|
+
CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
|
|
13
|
+
BEGIN
|
|
14
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
15
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
16
|
+
END;
|
|
17
|
+
|
|
18
|
+
DROP TRIGGER IF EXISTS feature_runs__du_au;
|
|
19
|
+
CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
|
|
20
|
+
BEGIN
|
|
21
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
22
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
23
|
+
END;
|
|
24
|
+
|
|
25
|
+
DROP VIEW IF EXISTS feature_runs__units;
|
|
26
|
+
CREATE VIEW feature_runs__units AS
|
|
27
|
+
SELECT
|
|
28
|
+
du.legacy_key AS feature_key,
|
|
29
|
+
du.repo AS repo,
|
|
30
|
+
du.issue_number AS issue_number,
|
|
31
|
+
du.issue_url AS issue_url,
|
|
32
|
+
du.base_branch AS base_branch,
|
|
33
|
+
du.status AS status,
|
|
34
|
+
du.process_key AS process_key,
|
|
35
|
+
du.pr_key AS pr_key,
|
|
36
|
+
du.converge AS converge,
|
|
37
|
+
du.auto_merge AS auto_merge,
|
|
38
|
+
du.human_approval AS human_approval,
|
|
39
|
+
du.outcome AS outcome,
|
|
40
|
+
du.created_at AS created_at,
|
|
41
|
+
du.updated_at AS updated_at,
|
|
42
|
+
du.delivery_label AS delivery_label,
|
|
43
|
+
du.title AS title,
|
|
44
|
+
du.acknowledged_at AS acknowledged_at,
|
|
45
|
+
du.stage AS stage,
|
|
46
|
+
du.stage_state AS stage_state,
|
|
47
|
+
du.stage_skipped AS stage_skipped,
|
|
48
|
+
du.attention AS attention,
|
|
49
|
+
du.list_bucket AS list_bucket
|
|
50
|
+
FROM delivery_units du
|
|
51
|
+
WHERE du.kind = 'feature';
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -62,6 +62,7 @@ interface FeatureRow {
|
|
|
62
62
|
}
|
|
63
63
|
interface PrRow {
|
|
64
64
|
pr_key: string;
|
|
65
|
+
process_key: string | null;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
@@ -184,6 +185,23 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
184
185
|
);
|
|
185
186
|
});
|
|
186
187
|
|
|
188
|
+
test("raise + converge + human approval: the enrolled convergence loop carries the approval gate (issue #826)", async () => {
|
|
189
|
+
await withApp(
|
|
190
|
+
{ "senior:feature": () => ({ status: "opened", pr: "owner/repo#103", summary: "built it" }) },
|
|
191
|
+
{ baseBranch: "epic/e2e", converge: true, autoMerge: true, humanApproval: true },
|
|
192
|
+
async ({ app }) => {
|
|
193
|
+
// The flag must survive the real handoff: feature.bpmn → converge-cell callActivity →
|
|
194
|
+
// pr.converge-feature → submitPr → the live loop instance (still ACTIVE, so its vars are readable).
|
|
195
|
+
const pr = await app.db.table<PrRow>("pull_requests", "pr_key").findOne({ pr_key: "owner/repo#103" });
|
|
196
|
+
assert.ok(pr?.process_key, "converge enrolled the raised PR into a live convergence loop");
|
|
197
|
+
const instances = app.snapshot().instances;
|
|
198
|
+
assert.ok(Array.isArray(instances), "snapshot.instances is an array of instance rows");
|
|
199
|
+
const loop = instances.find((i) => i?.key === pr.process_key);
|
|
200
|
+
assert.equal(loop?.variables?.humanApproval, true, "the convergence loop was seeded with humanApproval");
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
187
205
|
test("blocked: parks at the operators' user task (non-terminal), never enrolls a PR, settles on ack", async () => {
|
|
188
206
|
await withApp(
|
|
189
207
|
{ "senior:feature": () => ({ status: "blocked", summary: "could not proceed" }) },
|
package/openapi.yaml
CHANGED
|
@@ -3442,6 +3442,12 @@ components:
|
|
|
3442
3442
|
When true (and `converge` is true), drive the merge-loop after convergence only when
|
|
3443
3443
|
`NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`.
|
|
3444
3444
|
Moot when `converge` is false.
|
|
3445
|
+
humanApproval:
|
|
3446
|
+
type: boolean
|
|
3447
|
+
description: >-
|
|
3448
|
+
When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the
|
|
3449
|
+
merge or request changes; the agent applies them to the same PR and convergence re-runs.
|
|
3450
|
+
Moot when the run does not merge (see `autoMerge`).
|
|
3445
3451
|
confirmDefaultBase:
|
|
3446
3452
|
type: boolean
|
|
3447
3453
|
description: >-
|
|
@@ -3513,6 +3519,9 @@ components:
|
|
|
3513
3519
|
autoMerge:
|
|
3514
3520
|
type: boolean
|
|
3515
3521
|
description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
|
|
3522
|
+
humanApproval:
|
|
3523
|
+
type: boolean
|
|
3524
|
+
description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
|
|
3516
3525
|
confirmDefaultBase:
|
|
3517
3526
|
type: boolean
|
|
3518
3527
|
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -6181,6 +6190,9 @@ paths:
|
|
|
6181
6190
|
autoMerge:
|
|
6182
6191
|
type: boolean
|
|
6183
6192
|
description: When true (and `converge` is true), drive the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`. Moot when `converge` is false.
|
|
6193
|
+
humanApproval:
|
|
6194
|
+
type: boolean
|
|
6195
|
+
description: When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the merge or request changes; the agent applies them to the same PR and convergence re-runs. Moot when the run does not merge (see `autoMerge`).
|
|
6184
6196
|
confirmDefaultBase:
|
|
6185
6197
|
type: boolean
|
|
6186
6198
|
description: Acknowledge that `baseBranch` is the repository default branch (the PR would target it directly). See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -6326,6 +6338,9 @@ paths:
|
|
|
6326
6338
|
autoMerge:
|
|
6327
6339
|
type: boolean
|
|
6328
6340
|
description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
|
|
6341
|
+
humanApproval:
|
|
6342
|
+
type: boolean
|
|
6343
|
+
description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
|
|
6329
6344
|
confirmDefaultBase:
|
|
6330
6345
|
type: boolean
|
|
6331
6346
|
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
@@ -193,3 +193,17 @@ test("malformed readiness descriptor → 400 at the edge (never a 500)", async (
|
|
|
193
193
|
assertEquals(started.length, 0);
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
// ── human approval before merge (issue #826): threaded through the edge, pinned off without autoMerge ─
|
|
198
|
+
|
|
199
|
+
test("humanApproval reaches the run only together with autoMerge", async () => {
|
|
200
|
+
await withGithub(REPO, async () => {
|
|
201
|
+
for (const [autoMerge, expected] of [[true, true], [false, false]] as const) {
|
|
202
|
+
const { app, started } = makeApp();
|
|
203
|
+
const body = { issue: `${REPO}#577`, ...GATED_BASE, converge: true, autoMerge, humanApproval: true };
|
|
204
|
+
const res = (await startFeature(input(body), app)) as any;
|
|
205
|
+
assertEquals(res.status, 202);
|
|
206
|
+
assertEquals(started[0].variables?.humanApproval, expected);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -116,6 +116,8 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
116
116
|
// Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
|
|
117
117
|
// the persisted row and the process variable can't disagree.
|
|
118
118
|
const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
|
|
119
|
+
// Human approval gates the merge-loop, so it is moot (pinned off) without auto-merge (issue #826).
|
|
120
|
+
const humanApproval = autoMerge && "humanApproval" in body && body.humanApproval === true;
|
|
119
121
|
// Optional operator steering threaded to the implementation agent's prompt. Empty/whitespace is
|
|
120
122
|
// normalized to null downstream (startFeature) so it never appends an empty instruction block.
|
|
121
123
|
const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
|
|
@@ -176,12 +178,14 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
176
178
|
autoMerge,
|
|
177
179
|
customInstructions,
|
|
178
180
|
{ probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
|
|
181
|
+
humanApproval,
|
|
179
182
|
);
|
|
180
183
|
app.log.info("feature run intake", {
|
|
181
184
|
featureKey: parsed.planKey,
|
|
182
185
|
requestedBaseBranch: normalizedBase,
|
|
183
186
|
converge,
|
|
184
187
|
autoMerge,
|
|
188
|
+
humanApproval,
|
|
185
189
|
hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
|
|
186
190
|
readinessProbes: readiness.probes.length,
|
|
187
191
|
outcome: result.outcome,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.195.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/feature.page.json
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
{ "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
|
|
91
91
|
{ "key": "customInstructions", "label": "Custom instructions (optional) \u2014 extra steering appended to the implementation agent's prompt, e.g. \u201cprefer Deno; keep the diff small; add integration tests\u201d", "type": "text" },
|
|
92
92
|
{ "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
|
|
93
|
+
{ "key": "humanApproval", "label": "Human approval before merge \u2014 a converged PR waits in Tasks for you to approve the merge or request changes (only applies when Auto-merge is on)", "type": "checkbox" },
|
|
93
94
|
{ "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
|
|
94
95
|
{ "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
|
|
95
96
|
{ "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic", "type": "checkbox" }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "merge-approval",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "text",
|
|
8
|
+
"label": "Approve merge",
|
|
9
|
+
"text": "This PR passed review convergence. Approve it to hand it to the merge loop, or request changes: your guidance goes to the review agent, which updates this same PR. Convergence then re-runs and approval is asked again."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "select",
|
|
13
|
+
"key": "mergeDecision",
|
|
14
|
+
"label": "Decision",
|
|
15
|
+
"values": [
|
|
16
|
+
{ "label": "Approve — merge the PR", "value": "approve" },
|
|
17
|
+
{ "label": "Request changes — send guidance to the agent", "value": "revise" }
|
|
18
|
+
],
|
|
19
|
+
"validate": {
|
|
20
|
+
"required": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "textarea",
|
|
25
|
+
"key": "answer",
|
|
26
|
+
"label": "Guidance for the agent",
|
|
27
|
+
"description": "What should change before this PR merges? The agent implements it on the same PR.",
|
|
28
|
+
"validate": {
|
|
29
|
+
"required": true
|
|
30
|
+
},
|
|
31
|
+
"conditional": {
|
|
32
|
+
"hide": "=mergeDecision != \"revise\""
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|