@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.
@@ -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';
@@ -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.194.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",
@@ -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
+ }