@nanobpm/nano-workforce 0.57.0 → 0.58.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/SPEC.md +21 -22
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +0 -294
- package/app/plan.ts +26 -216
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +37 -11
- package/openapi.yaml +181 -73
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +0 -58
- package/operations/startFeature.ts +127 -0
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +52 -24
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +304 -223
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
|
@@ -7,13 +7,24 @@ import type { AppApi } from "@nanobpm/urban";
|
|
|
7
7
|
import { noopLog } from "../test/log.ts";
|
|
8
8
|
import handler from "./listActivePrs.ts";
|
|
9
9
|
|
|
10
|
-
function memApp(rows: any[]): AppApi {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
return
|
|
14
|
-
|
|
10
|
+
function memApp(rows: any[], escalations: any[] = []): AppApi {
|
|
11
|
+
const table = (name: string) => {
|
|
12
|
+
if (name === "escalations") {
|
|
13
|
+
return {
|
|
14
|
+
async find(where: Record<string, unknown>) {
|
|
15
|
+
return escalations.filter((e) =>
|
|
16
|
+
Object.entries(where).every(([k, v]) => e[k] === v)
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
async all() {
|
|
23
|
+
return rows;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
15
26
|
};
|
|
16
|
-
return { data: { table
|
|
27
|
+
return { data: { table }, log: noopLog() } as any as AppApi;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
function input(headers: Record<string, string> = {}) {
|
|
@@ -46,6 +57,28 @@ test("returns 200 with a count + projected active PRs", async () => {
|
|
|
46
57
|
assertEquals(r.body.prs[0].processKey, "9");
|
|
47
58
|
});
|
|
48
59
|
|
|
60
|
+
test("surfaces openEscalation for an escalated PR from its open escalations row (both loops)", async () => {
|
|
61
|
+
// Regression: a merge-loop escalation parks on a message catch (no user task), so deriving
|
|
62
|
+
// openEscalation from a user-task probe hid it. Deriving from the canonical `escalations` row
|
|
63
|
+
// surfaces it. Two escalated PRs — one with an open row (visible), one already answered (null).
|
|
64
|
+
const app = memApp(
|
|
65
|
+
[
|
|
66
|
+
{ pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
|
|
67
|
+
{ pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "answered", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
{ id: 1, pr_key: "o/r#10", status: "open", question: "Resolve the conflict on the branch, then retry?" },
|
|
71
|
+
{ id: 2, pr_key: "o/r#11", status: "answered", question: "old question" },
|
|
72
|
+
],
|
|
73
|
+
);
|
|
74
|
+
const res = (await handler(input(), app)) as any;
|
|
75
|
+
assertEquals(res.status, 200);
|
|
76
|
+
const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
|
|
77
|
+
const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
|
|
78
|
+
assertEquals(p10.openEscalation, "Resolve the conflict on the branch, then retry?");
|
|
79
|
+
assertEquals(p11.openEscalation, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
49
82
|
test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
50
83
|
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
51
84
|
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
// POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
|
|
2
2
|
// Replaces the hand-rolled action that overrode the generic publishMessage action. For the
|
|
3
|
-
// `escalation-answered` message we run the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// merge-loop `escalation-answered` message we run the merge-loop escalation answer flow; any other
|
|
4
|
+
// message falls back to a plain publishMessage.
|
|
5
|
+
//
|
|
6
|
+
// The four #156 escalation kinds (task, plan-review, trial-merge, PR review-loop) are now native
|
|
7
|
+
// `userTask`s answered directly through the task inbox (`POST /tasks/api/complete`), so this
|
|
8
|
+
// delegate no longer carries their bespoke `feature-escalation-answered` / `plan-escalation-answered`
|
|
9
|
+
// discriminators. The merge-loop escalation is still a durable message catch (out of scope for
|
|
10
|
+
// #156), so its `escalation-answered` branch is kept.
|
|
7
11
|
//
|
|
8
12
|
// The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
|
|
9
13
|
// for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
|
|
10
14
|
// is app logic, not something the JSON schema can express.
|
|
11
15
|
|
|
12
|
-
import {
|
|
13
|
-
answerPlanEscalation,
|
|
14
|
-
answerTaskEscalation,
|
|
15
|
-
FEATURE_ESCALATION_MESSAGE,
|
|
16
|
-
PLAN_ESCALATION_MESSAGE,
|
|
17
|
-
parsePlanEscalationDirective,
|
|
18
|
-
} from "../app/plan.ts";
|
|
19
16
|
import { answerEscalation } from "../app/service.ts";
|
|
20
17
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
21
18
|
|
|
@@ -33,36 +30,8 @@ export default defineOperation("postMessage", async ({ body }, app) => {
|
|
|
33
30
|
if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
34
31
|
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
35
32
|
const r = await answerEscalation(app.data, app.engine, prKey, answer);
|
|
36
|
-
if (r.ok) app.log.info("
|
|
37
|
-
else app.log.warn("postMessage: no open
|
|
38
|
-
return { status: r.ok ? 200 : 404, body: r };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (name === FEATURE_ESCALATION_MESSAGE) {
|
|
42
|
-
// Implementation-phase task escalation (issue #25): correlationKey is the task's
|
|
43
|
-
// `<plan_key>:<task_id>`; record the answer, resume the parked child, and re-surface the next
|
|
44
|
-
// open escalation.
|
|
45
|
-
const corrKey = String(b.correlationKey ?? "");
|
|
46
|
-
const answer = String(b.variables?.answer ?? "").trim();
|
|
47
|
-
if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
48
|
-
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
49
|
-
const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
|
|
50
|
-
if (r.ok) app.log.info("feature escalation answered", { name, corrKey });
|
|
51
|
-
else app.log.warn("postMessage: no open feature escalation to answer", { name, corrKey });
|
|
52
|
-
return { status: r.ok ? 200 : 404, body: r };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (name === PLAN_ESCALATION_MESSAGE) {
|
|
56
|
-
const planKey = String(b.correlationKey ?? "");
|
|
57
|
-
const directive = parsePlanEscalationDirective(b.variables?.directive ?? "revise");
|
|
58
|
-
const note = String(b.variables?.note ?? b.variables?.answer ?? "").trim();
|
|
59
|
-
if (!planKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
60
|
-
if (!directive) {
|
|
61
|
-
return { status: 400, body: { error: "directive must be proceed or revise" } };
|
|
62
|
-
}
|
|
63
|
-
const r = await answerPlanEscalation(app.data, app.engine, planKey, directive, note);
|
|
64
|
-
if (r.ok) app.log.info("plan escalation answered", { name, planKey, directive });
|
|
65
|
-
else app.log.warn("postMessage: no open plan escalation to answer", { name, planKey });
|
|
33
|
+
if (r.ok) app.log.info("merge-loop escalation answered", { name, prKey });
|
|
34
|
+
else app.log.warn("postMessage: no open merge-loop escalation to answer", { name, prKey });
|
|
66
35
|
return { status: r.ok ? 200 : 404, body: r };
|
|
67
36
|
}
|
|
68
37
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// POST /app/api/hooks/revert-completion → operationId `revertEscalationCompletion` (epic #156, slice
|
|
2
|
+
// U6; ADR 0046 reversibility). A completed user task cannot be un-completed in the engine, so an
|
|
3
|
+
// AGENT answer is never a silent irreversible commit: this endpoint lets a human mark a reversible
|
|
4
|
+
// agent completion reverted/overridden, recording who did it and when. Host-side consumers read the
|
|
5
|
+
// `task_completions` ledger to see whether the latest completion is still authoritative. Human
|
|
6
|
+
// completions are not reversible (they are already the authority), and a completion can be reverted
|
|
7
|
+
// only once. Optional shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET
|
|
8
|
+
// is set.
|
|
9
|
+
|
|
10
|
+
import { revertAgentCompletion } from "../app/agentCompletion.ts";
|
|
11
|
+
import { envVar } from "../app/version.ts";
|
|
12
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
13
|
+
|
|
14
|
+
const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
15
|
+
|
|
16
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
17
|
+
|
|
18
|
+
export default defineOperation("revertEscalationCompletion", async ({ req, body }, app) => {
|
|
19
|
+
if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
|
|
20
|
+
app.log.warn("revert-completion rejected: missing/invalid shared secret");
|
|
21
|
+
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
|
22
|
+
}
|
|
23
|
+
if (!body || typeof body !== "object") {
|
|
24
|
+
app.log.warn("revert-completion rejected: missing request body");
|
|
25
|
+
return { status: 400, body: { ok: false, error: "completionId and reverterId are required" } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const completionId = typeof body.completionId === "number" ? body.completionId : Number.NaN;
|
|
29
|
+
const reverterId = str(body.reverterId);
|
|
30
|
+
const note = str(body.note);
|
|
31
|
+
if (!Number.isInteger(completionId)) {
|
|
32
|
+
return { status: 400, body: { ok: false, error: "completionId must be an integer" } };
|
|
33
|
+
}
|
|
34
|
+
if (!reverterId) return { status: 400, body: { ok: false, error: "reverterId is required" } };
|
|
35
|
+
|
|
36
|
+
const r = await revertAgentCompletion(app.data, completionId, { kind: "human", id: reverterId }, note);
|
|
37
|
+
if (r.ok) {
|
|
38
|
+
app.log.info("agent completion reverted", { completionId, reverterId });
|
|
39
|
+
return { status: 200, body: { ok: true, completionId: r.completionId } };
|
|
40
|
+
}
|
|
41
|
+
const status = r.reason === "no such completion" ? 404 : 400;
|
|
42
|
+
app.log.warn("revert-completion: not reverted", { completionId, reason: r.reason });
|
|
43
|
+
return { status, body: { ok: false, error: r.reason } };
|
|
44
|
+
});
|
|
@@ -222,61 +222,3 @@ test("postMessage → 400 when escalation-answered lacks a correlationKey", asyn
|
|
|
222
222
|
assertEquals(r.status, 400);
|
|
223
223
|
assertEquals(r.body.error, "correlationKey is required");
|
|
224
224
|
});
|
|
225
|
-
|
|
226
|
-
function planEscalationMessageApp() {
|
|
227
|
-
const plans = [{ plan_key: "owner/repo#12", open_plan_escalation_id: 1 }];
|
|
228
|
-
const escalations = [{
|
|
229
|
-
id: 1,
|
|
230
|
-
plan_key: "owner/repo#12",
|
|
231
|
-
epoch: 0,
|
|
232
|
-
round: 2,
|
|
233
|
-
findings: "needs guidance",
|
|
234
|
-
status: "open",
|
|
235
|
-
directive: null,
|
|
236
|
-
note: null,
|
|
237
|
-
}];
|
|
238
|
-
const published: any[] = [];
|
|
239
|
-
const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
|
|
240
|
-
Object.entries(q).every(([f, v]) => r[f] === v);
|
|
241
|
-
const table = (rows: any[], key: string) => ({
|
|
242
|
-
find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
|
|
243
|
-
update: (id: any, patch: any) => {
|
|
244
|
-
const row = rows.find((r) => r[key] === id);
|
|
245
|
-
if (row) Object.assign(row, patch);
|
|
246
|
-
return Promise.resolve(row);
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
return {
|
|
250
|
-
app: {
|
|
251
|
-
data: {
|
|
252
|
-
table(name: string) {
|
|
253
|
-
return name === "plans" ? table(plans, "plan_key") : table(escalations, "id");
|
|
254
|
-
},
|
|
255
|
-
},
|
|
256
|
-
engine: {
|
|
257
|
-
publishMessage: (m: any) => {
|
|
258
|
-
published.push(m);
|
|
259
|
-
return Promise.resolve();
|
|
260
|
-
},
|
|
261
|
-
},
|
|
262
|
-
log: noopLog(),
|
|
263
|
-
} as any as AppApi,
|
|
264
|
-
escalations,
|
|
265
|
-
published,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
test("postMessage accepts mixed-case plan escalation directive like the dedicated hook", async () => {
|
|
270
|
-
const { app: msgApp, escalations, published } = planEscalationMessageApp();
|
|
271
|
-
const res = await postMessage(input({
|
|
272
|
-
name: "plan-escalation-answered",
|
|
273
|
-
correlationKey: "owner/repo#12",
|
|
274
|
-
variables: { directive: "PrOcEeD", note: "ship it" },
|
|
275
|
-
}), msgApp);
|
|
276
|
-
const r = res as any;
|
|
277
|
-
assertEquals(r.status, 200);
|
|
278
|
-
assertEquals(r.body.ok, true);
|
|
279
|
-
assertEquals(r.body.directive, "proceed");
|
|
280
|
-
assertEquals(escalations[0].directive, "proceed");
|
|
281
|
-
assertEquals(published[0].variables.planEscalationDirective, "proceed");
|
|
282
|
-
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// POST /app/api/actions/start/feature → operationId `startFeature` (ADR 0058/0059, base /app/api).
|
|
2
|
+
// The ONE door for starting a SINGLE-issue feature run — the "missing middle" between Epics
|
|
3
|
+
// (startPlanFanout) and PR convergence (startConvergenceLoop): hand one issue to a single
|
|
4
|
+
// implementation agent that raises exactly one PR, then OPTIONALLY converge + merge (issue #172).
|
|
5
|
+
//
|
|
6
|
+
// The request body is FLAT (`{ issue | url, baseBranch, converge?, autoMerge? }`), not wrapped in a
|
|
7
|
+
// `variables` envelope — a purpose-built operation, not a generic engine "start process" call. The
|
|
8
|
+
// body is a `oneOf` — EXACTLY ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at
|
|
9
|
+
// the edge; this delegate narrows the validated variant and keeps the issue-FORMAT parse guard.
|
|
10
|
+
//
|
|
11
|
+
// Base-branch handling is IDENTICAL to the epic path: it reuses `admitPlan` (ADR 0003) verbatim, so
|
|
12
|
+
// a feature run's PR is admitted through the same required+explicit / create-if-missing /
|
|
13
|
+
// confirm-default / shared-base rules, with the same typed-error → HTTP mapping.
|
|
14
|
+
|
|
15
|
+
import { startFeature } from "../app/feature.ts";
|
|
16
|
+
import { BaseBranchMustExistError } from "../app/github.ts";
|
|
17
|
+
import {
|
|
18
|
+
admitPlan,
|
|
19
|
+
DefaultBaseNotConfirmedError,
|
|
20
|
+
InvalidBaseBranchError,
|
|
21
|
+
MissingBaseBranchError,
|
|
22
|
+
parseIssue,
|
|
23
|
+
SharedBaseError,
|
|
24
|
+
} from "../app/plan.ts";
|
|
25
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
26
|
+
|
|
27
|
+
export default defineOperation("startFeature", async ({ body }, app) => {
|
|
28
|
+
// The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
|
|
29
|
+
// (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500 from `in`.
|
|
30
|
+
if (!body || typeof body !== "object") {
|
|
31
|
+
app.log.warn("start-feature rejected: missing request body");
|
|
32
|
+
return { status: 400, body: { error: "request body is required (owner/repo#123 or an issue URL)" } };
|
|
33
|
+
}
|
|
34
|
+
// The `oneOf` variant is normally narrowed by OpenAPI validation, but a directly-invoked delegate
|
|
35
|
+
// can pass a missing/mistyped `issue`/`url` — guard so that stays a 400, not a 500 from `.trim()`.
|
|
36
|
+
const target = "issue" in body ? body.issue : "url" in body ? body.url : undefined;
|
|
37
|
+
if (typeof target !== "string") {
|
|
38
|
+
app.log.warn("start-feature rejected: issue/url must be a string");
|
|
39
|
+
return { status: 400, body: { error: "issue or url must be a string (owner/repo#123 or an issue URL)" } };
|
|
40
|
+
}
|
|
41
|
+
const raw = target.trim();
|
|
42
|
+
const parsed = parseIssue(raw);
|
|
43
|
+
if (!parsed) {
|
|
44
|
+
app.log.warn("start-feature rejected: unparseable issue reference", { raw });
|
|
45
|
+
return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
|
|
46
|
+
}
|
|
47
|
+
// Base branch (ADR 0003): admit the launch through the same fail-fast `admitPlan` gate the epic
|
|
48
|
+
// path uses BEFORE starting the run. A feature run also raises a PR against a base, so it honors
|
|
49
|
+
// the identical admission policy and error → HTTP mapping.
|
|
50
|
+
const rawBase = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
|
|
51
|
+
const allowSharedBase = "allowSharedBase" in body && body.allowSharedBase === true;
|
|
52
|
+
const confirmDefaultBase = "confirmDefaultBase" in body && body.confirmDefaultBase === true;
|
|
53
|
+
const token = process.env.GITHUB_TOKEN ?? "";
|
|
54
|
+
let normalizedBase: string;
|
|
55
|
+
try {
|
|
56
|
+
// No `selfPlanKey`: a feature run creates a `feature_runs` row, NOT a `plans` row, so there is no
|
|
57
|
+
// own plan to exclude from the shared-base guard (rule 4). Passing `parsed.planKey` here would
|
|
58
|
+
// exclude an ACTIVE EPIC sharing the same `owner/repo#N` key, silently bypassing shared-base
|
|
59
|
+
// protection. Feature-run idempotency is enforced separately by `startFeature` on `feature_runs`.
|
|
60
|
+
normalizedBase = await admitPlan(app.data, parsed.repo, rawBase, token, {
|
|
61
|
+
allowSharedBase,
|
|
62
|
+
confirmDefaultBase,
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof MissingBaseBranchError) {
|
|
66
|
+
app.log.warn("start-feature rejected: missing base branch");
|
|
67
|
+
return {
|
|
68
|
+
status: 400,
|
|
69
|
+
body: { error: "baseBranch is required (name the branch the PR targets, e.g. main or epic/agent-protocol)" },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (err instanceof InvalidBaseBranchError) {
|
|
73
|
+
app.log.warn("start-feature rejected: invalid base branch", { baseBranch: err.value });
|
|
74
|
+
return {
|
|
75
|
+
status: 400,
|
|
76
|
+
body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. main)" },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (err instanceof BaseBranchMustExistError) {
|
|
80
|
+
app.log.warn("start-feature rejected: base branch does not exist", { baseBranch: err.branch });
|
|
81
|
+
return {
|
|
82
|
+
status: 400,
|
|
83
|
+
body: {
|
|
84
|
+
error:
|
|
85
|
+
`baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
|
|
86
|
+
`auto-created — create it first, or use the epic/* convention`,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (err instanceof DefaultBaseNotConfirmedError) {
|
|
91
|
+
app.log.warn("start-feature rejected: default base not confirmed", { baseBranch: err.branch });
|
|
92
|
+
return {
|
|
93
|
+
status: 400,
|
|
94
|
+
body: {
|
|
95
|
+
error:
|
|
96
|
+
`baseBranch "${err.branch}" is the repository default branch — the PR would target it ` +
|
|
97
|
+
`directly. Re-submit with confirmDefaultBase: true to proceed`,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (err instanceof SharedBaseError) {
|
|
102
|
+
app.log.warn("start-feature rejected: shared base branch", { baseBranch: err.branch });
|
|
103
|
+
return {
|
|
104
|
+
status: 409,
|
|
105
|
+
body: {
|
|
106
|
+
error:
|
|
107
|
+
`baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
|
|
108
|
+
`allowSharedBase: true to stack on it, or name a distinct branch`,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
const converge = "converge" in body && body.converge === true;
|
|
115
|
+
// Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
|
|
116
|
+
// the persisted row and the process variable can't disagree.
|
|
117
|
+
const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
|
|
118
|
+
const result = await startFeature(app.data, app.engine, parsed, normalizedBase, converge, autoMerge);
|
|
119
|
+
app.log.info("feature run started", {
|
|
120
|
+
featureKey: parsed.planKey,
|
|
121
|
+
requestedBaseBranch: normalizedBase,
|
|
122
|
+
converge,
|
|
123
|
+
autoMerge,
|
|
124
|
+
alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
|
|
125
|
+
});
|
|
126
|
+
return { status: 202, body: result };
|
|
127
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.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",
|
|
@@ -35,12 +35,15 @@
|
|
|
35
35
|
"typecheck": "tsc --noEmit",
|
|
36
36
|
"pretypecheck": "urban gen",
|
|
37
37
|
"check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
|
|
38
|
+
"check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
|
|
38
39
|
"gen": "urban gen",
|
|
39
40
|
"gen:check": "urban gen --check",
|
|
40
41
|
"layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
|
|
41
42
|
"layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
|
|
42
43
|
"dev": "urban dev",
|
|
44
|
+
"pretest": "urban gen",
|
|
43
45
|
"test": "node --experimental-strip-types --test",
|
|
46
|
+
"pree2e": "urban gen",
|
|
44
47
|
"e2e": "node --experimental-strip-types --test \"e2e/**/*.e2e.ts\"",
|
|
45
48
|
"lint": "biome check app operations workers pages components scripts e2e main.ts",
|
|
46
49
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
package/pages/cockpit.page.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"items": [
|
|
13
13
|
{ "label": "Convergence", "page": "home" },
|
|
14
14
|
{ "label": "Epics", "page": "epic" },
|
|
15
|
+
{ "label": "Feature", "page": "feature" },
|
|
15
16
|
{ "label": "Cockpit", "page": "cockpit" }
|
|
16
17
|
]
|
|
17
18
|
}
|
|
@@ -29,6 +30,14 @@
|
|
|
29
30
|
"variant": "sub"
|
|
30
31
|
}
|
|
31
32
|
},
|
|
33
|
+
{
|
|
34
|
+
"type": "text",
|
|
35
|
+
"id": "escalations-pointer",
|
|
36
|
+
"props": {
|
|
37
|
+
"text": "Escalations (task, plan-review, trial-merge) are native user tasks — answer them from the Task inbox at /tasks: list the open tasks, pick the one for this epic, and submit the typed decision. There is no separate answer form on this page.",
|
|
38
|
+
"variant": "sub"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
32
41
|
{
|
|
33
42
|
"type": "dataGrid",
|
|
34
43
|
"id": "epic-plan",
|
|
@@ -48,51 +57,16 @@
|
|
|
48
57
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
49
58
|
{ "field": "wave_label", "header": "Wave" },
|
|
50
59
|
{ "field": "task_count", "header": "Tasks" },
|
|
51
|
-
{ "field": "open_task_id", "header": "Open escalation" },
|
|
52
|
-
{ "field": "open_plan_round", "header": "Plan escalation round" },
|
|
53
60
|
{ "field": "updated_at", "header": "Updated" }
|
|
54
61
|
],
|
|
55
|
-
"rowActions": [
|
|
56
|
-
{
|
|
57
|
-
"label": "Proceed with plan",
|
|
58
|
-
"confirm": "Dispatch the current unapproved plan as a human override?",
|
|
59
|
-
"showWhenField": "open_plan_escalation_id",
|
|
60
|
-
"action": {
|
|
61
|
-
"path": "/app/api/actions/message",
|
|
62
|
-
"body": {
|
|
63
|
-
"name": "plan-escalation-answered",
|
|
64
|
-
"correlationKey": "{{row.plan_key}}",
|
|
65
|
-
"variables": { "directive": "proceed", "note": "Proceed override from the epic page." }
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
],
|
|
70
62
|
"detail": {
|
|
71
63
|
"linkField": "issue_url",
|
|
72
64
|
"fields": [
|
|
73
65
|
{ "field": "repo", "label": "Repository" },
|
|
74
66
|
{ "field": "issue_number", "label": "Issue number" },
|
|
75
67
|
{ "field": "base_branch", "label": "Base branch (blank = repo default)" },
|
|
76
|
-
{ "field": "outcome", "label": "Outcome" }
|
|
77
|
-
|
|
78
|
-
{ "field": "open_task_question", "label": "Open escalation question" }
|
|
79
|
-
],
|
|
80
|
-
"form": {
|
|
81
|
-
"showWhenField": "open_plan_escalation_id",
|
|
82
|
-
"title": "Answer the open plan-review escalation",
|
|
83
|
-
"promptField": "open_plan_findings",
|
|
84
|
-
"inputKey": "note",
|
|
85
|
-
"inputLabel": "Revision directive note for the planner",
|
|
86
|
-
"submitLabel": "Revise plan",
|
|
87
|
-
"action": {
|
|
88
|
-
"path": "/app/api/actions/message",
|
|
89
|
-
"body": {
|
|
90
|
-
"name": "plan-escalation-answered",
|
|
91
|
-
"correlationKey": "{{row.plan_key}}",
|
|
92
|
-
"variables": { "directive": "revise", "note": "{{form.note}}" }
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
68
|
+
{ "field": "outcome", "label": "Outcome" }
|
|
69
|
+
]
|
|
96
70
|
}
|
|
97
71
|
}
|
|
98
72
|
},
|
package/pages/epic.page.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"items": [
|
|
13
13
|
{ "label": "Convergence", "page": "home" },
|
|
14
14
|
{ "label": "Epics", "page": "epic" },
|
|
15
|
+
{ "label": "Feature", "page": "feature" },
|
|
15
16
|
{ "label": "Cockpit", "page": "cockpit" }
|
|
16
17
|
]
|
|
17
18
|
}
|
|
@@ -75,7 +76,6 @@
|
|
|
75
76
|
{ "field": "base_branch", "header": "Base branch" },
|
|
76
77
|
{ "field": "wave_label", "header": "Wave" },
|
|
77
78
|
{ "field": "task_count", "header": "Tasks" },
|
|
78
|
-
{ "field": "open_plan_findings", "header": "Attention", "badge": { "tone": "danger", "label": "!" } },
|
|
79
79
|
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
|
80
80
|
{ "field": "updated_at", "header": "Updated" }
|
|
81
81
|
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"title": "Feature Run",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"sticky": true,
|
|
11
|
+
"title": "Nano Workforce",
|
|
12
|
+
"items": [
|
|
13
|
+
{ "label": "Convergence", "page": "home" },
|
|
14
|
+
{ "label": "Epics", "page": "epic" },
|
|
15
|
+
{ "label": "Feature", "page": "feature" },
|
|
16
|
+
{ "label": "Cockpit", "page": "cockpit" }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "text",
|
|
22
|
+
"id": "title",
|
|
23
|
+
"props": { "text": "Single-issue feature run", "variant": "heading" }
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"type": "text",
|
|
27
|
+
"id": "subtitle",
|
|
28
|
+
"props": {
|
|
29
|
+
"text": "Hand one issue to a single implementation agent — it raises exactly one PR. Optionally converge (review rounds) and auto-merge as follow-on steps. The missing middle between Epics (many PRs) and PR convergence (an already-open PR).",
|
|
30
|
+
"variant": "sub"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"type": "actionForm",
|
|
35
|
+
"id": "feature-submit",
|
|
36
|
+
"props": {
|
|
37
|
+
"title": "Implement one issue",
|
|
38
|
+
"submitLabel": "Implement & raise PR",
|
|
39
|
+
"action": { "path": "/app/api/actions/start/feature", "body": "{{form}}" },
|
|
40
|
+
"fields": [
|
|
41
|
+
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
|
|
42
|
+
{ "key": "baseBranch", "label": "Base branch (REQUIRED; 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" },
|
|
43
|
+
{ "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
|
|
44
|
+
{ "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
|
|
45
|
+
{ "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
|
|
46
|
+
{ "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic", "type": "checkbox" }
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"type": "dataGrid",
|
|
52
|
+
"id": "feature-runs",
|
|
53
|
+
"props": {
|
|
54
|
+
"title": "Feature runs",
|
|
55
|
+
"rowKey": "feature_key",
|
|
56
|
+
"refreshMs": 5000,
|
|
57
|
+
"data": {
|
|
58
|
+
"kind": "datasource",
|
|
59
|
+
"source": "app",
|
|
60
|
+
"table": "feature_runs",
|
|
61
|
+
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
62
|
+
"filter": [{ "field": "status", "in": ["running"] }]
|
|
63
|
+
},
|
|
64
|
+
"tabs": [
|
|
65
|
+
{ "label": "Active", "filter": [{ "field": "status", "in": ["running"] }] },
|
|
66
|
+
{ "label": "History", "filter": [{ "field": "status", "in": ["opened", "converging", "blocked", "skipped", "failed", "abandoned"] }] },
|
|
67
|
+
{ "label": "All", "filter": [] }
|
|
68
|
+
],
|
|
69
|
+
"columns": [
|
|
70
|
+
{ "field": "feature_key", "header": "Feature", "linkField": "issue_url" },
|
|
71
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
72
|
+
{ "field": "base_branch", "header": "Base branch" },
|
|
73
|
+
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
74
|
+
{ "field": "converge", "header": "Converge" },
|
|
75
|
+
{ "field": "auto_merge", "header": "Auto-merge" },
|
|
76
|
+
{ "field": "outcome", "header": "Outcome" },
|
|
77
|
+
{ "field": "updated_at", "header": "Updated" }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
package/pages/home.page.json
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"label": "Epics",
|
|
18
18
|
"page": "epic"
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Feature",
|
|
22
|
+
"page": "feature"
|
|
23
|
+
},
|
|
20
24
|
{
|
|
21
25
|
"label": "Cockpit",
|
|
22
26
|
"page": "cockpit"
|
|
@@ -37,7 +41,7 @@
|
|
|
37
41
|
"type": "text",
|
|
38
42
|
"id": "subtitle",
|
|
39
43
|
"props": {
|
|
40
|
-
"text": "Submit a pull request to run the autonomous review-convergence loop. Answer escalations
|
|
44
|
+
"text": "Submit a pull request to run the autonomous review-convergence loop. Answer escalations from the Task inbox at /tasks; cancel a run at any time.",
|
|
41
45
|
"variant": "sub"
|
|
42
46
|
}
|
|
43
47
|
},
|
|
@@ -329,23 +333,7 @@
|
|
|
329
333
|
}
|
|
330
334
|
]
|
|
331
335
|
}
|
|
332
|
-
]
|
|
333
|
-
"form": {
|
|
334
|
-
"showWhenField": "open_escalation_id",
|
|
335
|
-
"title": "Answer the open escalation",
|
|
336
|
-
"promptField": "open_escalation_question",
|
|
337
|
-
"inputKey": "answer",
|
|
338
|
-
"inputLabel": "Your answer",
|
|
339
|
-
"submitLabel": "Send answer",
|
|
340
|
-
"action": {
|
|
341
|
-
"path": "/app/api/actions/message",
|
|
342
|
-
"body": {
|
|
343
|
-
"name": "escalation-answered",
|
|
344
|
-
"correlationKey": "{{row.pr_key}}",
|
|
345
|
-
"variables": "{{form}}"
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
336
|
+
]
|
|
349
337
|
}
|
|
350
338
|
}
|
|
351
339
|
}
|