@nanobpm/nano-workforce 0.202.0 → 0.203.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/SPEC.md +48 -0
- package/app/admittedStaging.test.ts +71 -0
- package/app/agentActivationLedger.ts +5 -0
- package/app/agentCompletion.test.ts +33 -0
- package/app/agentCompletion.ts +108 -2
- package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
- package/app/contracts.ts +23 -0
- package/app/deliveryGraphDispatch.ts +6 -0
- package/app/deliveryGraphReadModel.test.ts +2 -2
- package/app/deliveryGraphRun.ts +18 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/escalationSteward.test.ts +452 -0
- package/app/escalationSteward.ts +537 -0
- package/app/escalationStewardPoll.test.ts +123 -0
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +11 -0
- package/app/featureDelivery.test.ts +23 -0
- package/app/mcpToolSurface.ts +17 -1
- package/app/plan.ts +49 -6
- package/app/planLowering.test.ts +26 -1
- package/app/planLowering.ts +5 -0
- package/app/promotionPoll.test.ts +19 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +36 -0
- package/app/service.ts +139 -5
- package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
- package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
- package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
- package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
- package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
- package/docs/agent-guide.md +69 -30
- package/docs/mcp-runbook.md +2 -0
- package/e2e/escalation-steward.e2e.ts +211 -0
- package/nano.app.json +10 -1
- package/openapi.yaml +365 -0
- package/operations/dispatchDeliveryGraph.ts +6 -1
- package/operations/listActivePrs.test.ts +60 -1
- package/operations/listActivePrs.ts +3 -3
- package/operations/listAutoResolutions.ts +83 -0
- package/operations/reviewAutoResolution.test.ts +137 -0
- package/operations/reviewAutoResolution.ts +42 -0
- package/operations/startConvergenceLoop.ts +5 -1
- package/operations/startEpicSet.admission.integration.test.ts +2 -2
- package/operations/startEpicSet.ts +8 -2
- package/operations/startFeature.ts +5 -0
- package/operations/startPlanFanout.ts +4 -1
- package/package.json +1 -1
- package/pages/_nav.json +12 -0
- package/pages/auto-resolutions.page.json +191 -0
- package/pages/board.page.json +17 -0
- package/pages/cockpit.page.json +17 -0
- package/pages/delivery-graph-detail.page.json +17 -0
- package/pages/delivery-graphs/staged.mount.js +17 -5
- package/pages/delivery-graphs.page.json +17 -0
- package/pages/epic-detail.page.json +17 -0
- package/pages/epic.page.json +19 -1
- package/pages/feature.page.json +19 -1
- package/pages/home.page.json +22 -0
- package/pages/lineage.page.json +17 -0
- package/pages/mcp.page.json +17 -0
- package/pages/overview.page.json +17 -0
- package/pages/tasks.page.json +17 -0
- package/pages/velocity.page.json +17 -0
- package/resources/processes/escalation-steward.bpmn +162 -0
- package/resources/prompts/escalation-steward.md +87 -0
- package/test/delivery-graphs-staged-embed.test.ts +13 -0
- package/test/derivation-parity/derivation-parity.test.ts +1 -0
- package/test/derivation-parity/flows.ts +12 -0
- package/workers/converge-feature/worker.ts +6 -1
- package/workers/delivery-connector/worker.test.ts +72 -1
- package/workers/delivery-connector/worker.ts +53 -2
- package/workers/escalation-steward-collect/worker.test.ts +180 -0
- package/workers/escalation-steward-collect/worker.ts +99 -0
- package/workers/escalation-steward-record/worker.test.ts +740 -0
- package/workers/escalation-steward-record/worker.ts +428 -0
- package/workers/record-wave/worker.ts +5 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// GET /app/api/auto-resolutions → operationId `listAutoResolutions` (issue #889). The read door for
|
|
2
|
+
// the Auto-resolutions view + the matching MCP tool: list the escalation steward's durable,
|
|
3
|
+
// reviewable records (every escalation it resolved or abstained from), newest first, filtered by
|
|
4
|
+
// `reviewState` / `subject` / `kind`. Read-only projection over the ONE `escalation_auto_resolutions`
|
|
5
|
+
// table (app/escalationSteward.ts) — no second source of truth. The optional shared-secret guard
|
|
6
|
+
// mirrors `listEscalations`.
|
|
7
|
+
import {
|
|
8
|
+
type EscalationAutoResolution,
|
|
9
|
+
listAutoResolutions,
|
|
10
|
+
pendingAutoResolutionCount,
|
|
11
|
+
} from "../app/escalationSteward.ts";
|
|
12
|
+
import { envVar } from "../app/version.ts";
|
|
13
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
14
|
+
|
|
15
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
16
|
+
|
|
17
|
+
/** Project one stored row into its API/read-tool entry (camelCase, parsed JSON fields). */
|
|
18
|
+
function toView(r: EscalationAutoResolution) {
|
|
19
|
+
const parseJsonArray = (s: string | null): unknown[] | null => {
|
|
20
|
+
if (s == null) return null;
|
|
21
|
+
try {
|
|
22
|
+
const v: unknown = JSON.parse(s);
|
|
23
|
+
return Array.isArray(v) ? v : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const parseJsonObject = (s: string | null): { [key: string]: unknown } | null => {
|
|
29
|
+
if (s == null) return null;
|
|
30
|
+
try {
|
|
31
|
+
const v: unknown = JSON.parse(s);
|
|
32
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
33
|
+
// biome-ignore lint/plugin: narrowing a parsed JSON object to a string-keyed record (validated object above)
|
|
34
|
+
return v as { [key: string]: unknown };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
id: r.id,
|
|
43
|
+
userTaskKey: r.user_task_key,
|
|
44
|
+
kind: r.escalation_kind,
|
|
45
|
+
elementId: r.element_id,
|
|
46
|
+
subjectType: r.subject_type,
|
|
47
|
+
subjectKey: r.subject_key,
|
|
48
|
+
subjectUrl: r.subject_url,
|
|
49
|
+
processInstanceKey: r.process_instance_key,
|
|
50
|
+
question: r.question,
|
|
51
|
+
options: parseJsonArray(r.options),
|
|
52
|
+
outcome: r.outcome === "resolved" ? ("resolved" as const) : ("abstained" as const),
|
|
53
|
+
submittedVariables: parseJsonObject(r.submitted_variables),
|
|
54
|
+
// The first-class "what the steward chose" value (issue #889 review): derived host-side at record
|
|
55
|
+
// time, so consumers read it directly instead of decoding `submittedVariables`. NULL when abstained.
|
|
56
|
+
selectedOption: r.selected_option ?? null,
|
|
57
|
+
rationale: r.rationale,
|
|
58
|
+
evidence: r.evidence,
|
|
59
|
+
stewardInstanceKey: r.steward_instance_key,
|
|
60
|
+
agentJobKey: r.agent_job_key,
|
|
61
|
+
worker: r.worker,
|
|
62
|
+
model: r.model,
|
|
63
|
+
createdAt: r.created_at,
|
|
64
|
+
reviewState: r.review_state,
|
|
65
|
+
reviewedBy: r.reviewed_by,
|
|
66
|
+
reviewedAt: r.reviewed_at,
|
|
67
|
+
reviewNote: r.review_note,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default defineOperation("listAutoResolutions", async ({ query, req }, app) => {
|
|
72
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
73
|
+
app.log.warn("listAutoResolutions rejected: missing/invalid shared secret");
|
|
74
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
75
|
+
}
|
|
76
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined);
|
|
77
|
+
const reviewState = str(query.reviewState);
|
|
78
|
+
const subject = str(query.subject);
|
|
79
|
+
const kind = str(query.kind);
|
|
80
|
+
const records = (await listAutoResolutions(app.data, { reviewState, subjectKey: subject, kind })).map(toView);
|
|
81
|
+
const pendingCount = await pendingAutoResolutionCount(app.data);
|
|
82
|
+
return { status: 200, body: { count: records.length, pendingCount, records } };
|
|
83
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Integration coverage for the POST /app/api/auto-resolutions/review operation `reviewAutoResolution`
|
|
2
|
+
// (issue #889) — the Auto-resolutions view's Dismiss/Intervene affordance. These tests drive the REAL
|
|
3
|
+
// door through `bootTestApp`'s api driver and assert the OPERATOR-PAGE authorization pattern (issue
|
|
4
|
+
// #889 review): like every other UI-driven operator door (`acknowledgePr`/`acknowledgeDone`/
|
|
5
|
+
// `dismissProposal`), it carries NO `x-hook-secret` guard — declarative page actions structurally
|
|
6
|
+
// cannot attach that header, so guarding on it would 401 the very UI it exists to serve on a secured
|
|
7
|
+
// deployment. The guard is the read-model's derived `pending_review` flag, enforced host-side by
|
|
8
|
+
// `reviewResolution` (only a FINAL, still-`pending` record transitions).
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { after, describe, test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
15
|
+
import { claimEscalations, escalationAutoResolutions, recordResolution } from "../app/escalationSteward.ts";
|
|
16
|
+
import type { UserTaskRow } from "../app/userTasks.ts";
|
|
17
|
+
|
|
18
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
19
|
+
|
|
20
|
+
function taskRow(over: Partial<UserTaskRow>): UserTaskRow {
|
|
21
|
+
return {
|
|
22
|
+
user_task_key: "UT-1",
|
|
23
|
+
element_id: "wait-answer",
|
|
24
|
+
kind_label: "PR review",
|
|
25
|
+
subject_type: "pr",
|
|
26
|
+
subject_key: "o/r#1",
|
|
27
|
+
subject_title: "o/r#1",
|
|
28
|
+
subject_url: "https://github.com/o/r/pull/1",
|
|
29
|
+
question: "Which approach?",
|
|
30
|
+
process_key: "PI-1",
|
|
31
|
+
form_key: "pr-escalation",
|
|
32
|
+
created_at: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
33
|
+
updated_at: new Date().toISOString(),
|
|
34
|
+
...over,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("reviewAutoResolution — operator-page authorization (no x-hook-secret)", () => {
|
|
39
|
+
const dirs: string[] = [];
|
|
40
|
+
const apps: TestApp[] = [];
|
|
41
|
+
after(async () => {
|
|
42
|
+
for (const app of apps) await app.stop?.();
|
|
43
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
// Boot with the webhook secret SET — the deployment the finding cited. A declarative page action
|
|
46
|
+
// cannot attach `x-hook-secret`, so the door must NOT require it (the operator-page pattern).
|
|
47
|
+
const boot = async (): Promise<TestApp> => {
|
|
48
|
+
const d = mkdtempSync(join(tmpdir(), "nwf-review-auto-"));
|
|
49
|
+
dirs.push(d);
|
|
50
|
+
const app = await bootTestApp(APP_ROOT, {
|
|
51
|
+
env: { NANO_APP_DB_URL: `file:${join(d, "app.db")}`, NANO_PR_WEBHOOK_SECRET: "s3cr3t" },
|
|
52
|
+
});
|
|
53
|
+
apps.push(app);
|
|
54
|
+
return app;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const seedPending = async (app: TestApp): Promise<number> => {
|
|
58
|
+
await claimEscalations(app.db, [taskRow({})], "steward-A");
|
|
59
|
+
await recordResolution(app.db, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" }, rationale: "ok" });
|
|
60
|
+
return (await escalationAutoResolutions(app.db).find({ user_task_key: "UT-1" }))[0].id;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test("a secured deployment still serves the UI: dismiss succeeds WITHOUT an x-hook-secret header", async () => {
|
|
64
|
+
const app = await boot();
|
|
65
|
+
assert.ok(app.api);
|
|
66
|
+
const id = await seedPending(app);
|
|
67
|
+
// No `x-hook-secret` header — exactly what a declarative page action sends. Must NOT 401.
|
|
68
|
+
const res = await app.api.call<{ ok: boolean; reviewState?: string }>("reviewAutoResolution", { body: { id, action: "dismiss" } });
|
|
69
|
+
assert.equal(res.status, 200, "the operator-page door is reachable from the UI on a secured deployment");
|
|
70
|
+
assert.equal(res.body.ok, true);
|
|
71
|
+
assert.equal(res.body.reviewState, "dismissed");
|
|
72
|
+
const row = (await escalationAutoResolutions(app.db).find({ user_task_key: "UT-1" }))[0];
|
|
73
|
+
assert.equal(row.review_state, "dismissed");
|
|
74
|
+
assert.equal(row.reviewed_by, "operator");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("intervene records the operator note; a re-review is an idempotent 404 no-op", async () => {
|
|
78
|
+
const app = await boot();
|
|
79
|
+
assert.ok(app.api);
|
|
80
|
+
const id = await seedPending(app);
|
|
81
|
+
const res = await app.api.call<{ ok: boolean; reviewState?: string }>("reviewAutoResolution", {
|
|
82
|
+
body: { id, action: "intervene", operator: "alice", note: "stepping in" },
|
|
83
|
+
});
|
|
84
|
+
assert.equal(res.status, 200);
|
|
85
|
+
assert.equal(res.body.reviewState, "intervened");
|
|
86
|
+
let row = (await escalationAutoResolutions(app.db).find({ user_task_key: "UT-1" }))[0];
|
|
87
|
+
assert.equal(row.review_state, "intervened");
|
|
88
|
+
assert.equal(row.reviewed_by, "alice");
|
|
89
|
+
assert.equal(row.review_note, "stepping in");
|
|
90
|
+
// Already off `pending` → the guard folds it to a clean 404 no-op (not a double-stamp).
|
|
91
|
+
const again = await app.api.call<{ ok: boolean; error?: string }>("reviewAutoResolution", { body: { id, action: "dismiss" } });
|
|
92
|
+
assert.equal(again.status, 404);
|
|
93
|
+
assert.equal(again.body.ok, false);
|
|
94
|
+
row = (await escalationAutoResolutions(app.db).find({ user_task_key: "UT-1" }))[0];
|
|
95
|
+
assert.equal(row.review_state, "intervened", "the first review is preserved");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a missing/invalid id or action → 400 (schema + handler validation)", async () => {
|
|
99
|
+
const app = await boot();
|
|
100
|
+
assert.ok(app.api);
|
|
101
|
+
// Missing id → the handler's own 400.
|
|
102
|
+
const noId = await app.api.call<{ ok: boolean; error?: string }>("reviewAutoResolution", { body: { action: "dismiss" } });
|
|
103
|
+
assert.equal(noId.status, 400);
|
|
104
|
+
// An out-of-enum action → the OpenAPI schema rejects it before the handler runs.
|
|
105
|
+
const badAction = await app.api.call<{ error?: string }>("reviewAutoResolution", { body: { id: 1, action: "nuke" } });
|
|
106
|
+
assert.equal(badAction.status, 400);
|
|
107
|
+
assert.ok(typeof badAction.body.error === "string" && badAction.body.error.length > 0, "a human-readable error is returned");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Red-first regression for the removed secret guard (issue #889 review): even with
|
|
111
|
+
// NANO_PR_WEBHOOK_SECRET set, the operator door must NOT 401 a request carrying no/wrong
|
|
112
|
+
// `x-hook-secret` — declarative page actions cannot attach one. Drive the handler DIRECTLY with a
|
|
113
|
+
// cache-busted import so the module reads the secret env (the full-app boot imports it once, before
|
|
114
|
+
// the per-test env applies — the same module-load timing `cancelInstance.test.ts` works around).
|
|
115
|
+
test("the handler never 401s a secret-less request, even with NANO_PR_WEBHOOK_SECRET set", async () => {
|
|
116
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
117
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
118
|
+
try {
|
|
119
|
+
const mod = await import(`./reviewAutoResolution.ts?guard=${Date.now()}`);
|
|
120
|
+
const handler = mod.default as (input: unknown, app: unknown) => Promise<{ status: number; body: { ok: boolean } }>;
|
|
121
|
+
const d = mkdtempSync(join(tmpdir(), "nwf-review-direct-"));
|
|
122
|
+
dirs.push(d);
|
|
123
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(d, "app.db")}` } });
|
|
124
|
+
apps.push(app);
|
|
125
|
+
await claimEscalations(app.db, [taskRow({})], "steward-A");
|
|
126
|
+
await recordResolution(app.db, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" }, rationale: "ok" });
|
|
127
|
+
const id = (await escalationAutoResolutions(app.db).find({ user_task_key: "UT-1" }))[0].id;
|
|
128
|
+
const mkReq = (headers: Record<string, string>) => ({ req: { headers: new Headers(headers) }, params: {}, query: {}, body: { id, action: "dismiss" } });
|
|
129
|
+
const noSecret = await handler(mkReq({}), { data: app.db, log: { info() {}, warn() {}, error() {} } });
|
|
130
|
+
assert.notEqual(noSecret.status, 401, "no secret header is NOT a 401 — the UI cannot attach one");
|
|
131
|
+
assert.equal(noSecret.status, 200);
|
|
132
|
+
} finally {
|
|
133
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
134
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// POST /app/api/auto-resolutions/review → operationId `reviewAutoResolution` (issue #889). The
|
|
2
|
+
// Auto-resolutions view's review affordance: mark a steward auto-resolution `dismissed` (reviewed,
|
|
3
|
+
// no action) or `intervened` (the operator stepped in), with an optional note. Both fold the
|
|
4
|
+
// record's `review_state` off `pending` — idempotently, so an already-reviewed record is a 404-style
|
|
5
|
+
// no-op — and record who reviewed it and when. Reopening the completed user task is NOT required.
|
|
6
|
+
//
|
|
7
|
+
// AUTHORIZATION (issue #889 review): this is an OPERATOR-PAGE mutation, driven by the declarative
|
|
8
|
+
// Dismiss/Intervene row actions + the guidance form on `pages/auto-resolutions.page.json`. Like every
|
|
9
|
+
// other UI-driven operator door (`acknowledgePr`/`acknowledgeDone`/`acknowledgeEpic`/`dismissProposal`)
|
|
10
|
+
// it carries NO `x-hook-secret` guard — declarative page actions structurally cannot attach that header
|
|
11
|
+
// (see `pages/mcp.page.json`), so guarding on it would 401 the very UI it exists to serve on any
|
|
12
|
+
// secured deployment. The guard is instead the read-model's derived `pending_review` flag the button
|
|
13
|
+
// reads via `showWhenField`, enforced host-side by `reviewResolution` (which only transitions a FINAL,
|
|
14
|
+
// still-`pending` record) — the same "affordance and guard read one oracle" pattern as `acknowledgeVia`.
|
|
15
|
+
import { reviewResolution } from "../app/escalationSteward.ts";
|
|
16
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
17
|
+
|
|
18
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
19
|
+
|
|
20
|
+
export default defineOperation("reviewAutoResolution", async ({ body }, app) => {
|
|
21
|
+
if (!body || typeof body !== "object") {
|
|
22
|
+
app.log.warn("reviewAutoResolution rejected: missing request body");
|
|
23
|
+
return { status: 400, body: { ok: false, error: "id and action are required" } };
|
|
24
|
+
}
|
|
25
|
+
const id = typeof body.id === "number" && Number.isInteger(body.id) ? body.id : NaN;
|
|
26
|
+
if (!Number.isFinite(id)) return { status: 400, body: { ok: false, error: "id must be an integer" } };
|
|
27
|
+
const actionRaw = str(body.action);
|
|
28
|
+
if (actionRaw !== "dismiss" && actionRaw !== "intervene") {
|
|
29
|
+
return { status: 400, body: { ok: false, error: "action must be `dismiss` or `intervene`" } };
|
|
30
|
+
}
|
|
31
|
+
const operator = str(body.operator) || "operator";
|
|
32
|
+
const note = str(body.note) || null;
|
|
33
|
+
// `dismiss` → review_state `dismissed`; `intervene` → `intervened`.
|
|
34
|
+
const reviewState = actionRaw === "dismiss" ? "dismissed" : "intervened";
|
|
35
|
+
const changed = await reviewResolution(app.data, id, reviewState, operator, note);
|
|
36
|
+
if (!changed) {
|
|
37
|
+
app.log.warn("reviewAutoResolution: no pending record", { id });
|
|
38
|
+
return { status: 404, body: { ok: false, error: "no pending auto-resolution record with that id (already reviewed, or unknown)" } };
|
|
39
|
+
}
|
|
40
|
+
app.log.info("auto-resolution reviewed", { id, action: actionRaw, operator });
|
|
41
|
+
return { status: 200, body: { ok: true, id, reviewState } };
|
|
42
|
+
});
|
|
@@ -35,13 +35,17 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
|
|
|
35
35
|
// callers that omit autoMerge retain the existing convergeOnly/global-default behavior.
|
|
36
36
|
const hasAutoMerge = typeof body.autoMerge === "boolean";
|
|
37
37
|
const convergeOnly = hasAutoMerge ? body.autoMerge !== true : body.convergeOnly === true;
|
|
38
|
-
|
|
38
|
+
// Opt-in escalation steward (issue #889): only when explicitly requested do this loop's
|
|
39
|
+
// escalations become eligible for steward auto-resolution. Default off.
|
|
40
|
+
const autoResolveEscalations = "autoResolveEscalations" in body && body.autoResolveEscalations === true;
|
|
41
|
+
const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly, null, false, autoResolveEscalations);
|
|
39
42
|
app.log.info("convergence loop started", {
|
|
40
43
|
prKey: parsed.prKey,
|
|
41
44
|
alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
|
|
42
45
|
dependsOn: dependsOn.length,
|
|
43
46
|
maxRounds,
|
|
44
47
|
convergeOnly,
|
|
48
|
+
autoResolveEscalations,
|
|
45
49
|
});
|
|
46
50
|
return { status: 202, body: result };
|
|
47
51
|
});
|
|
@@ -498,13 +498,13 @@ function makeSqliteApp(
|
|
|
498
498
|
db.exec(
|
|
499
499
|
"CREATE TABLE plans (plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, " +
|
|
500
500
|
"title TEXT, base_branch TEXT, status TEXT, task_count INTEGER, epic_phase TEXT, " +
|
|
501
|
-
"blackboard_token TEXT, list_bucket TEXT, ack_open INTEGER, created_at TEXT, updated_at TEXT);",
|
|
501
|
+
"blackboard_token TEXT, list_bucket TEXT, ack_open INTEGER, auto_resolve_escalations INTEGER, created_at TEXT, updated_at TEXT);",
|
|
502
502
|
);
|
|
503
503
|
for (const p of seedPlans) {
|
|
504
504
|
db.prepare("INSERT INTO plans (plan_key, repo, base_branch, status) VALUES (?, ?, ?, ?)")
|
|
505
505
|
.run(p.plan_key, p.repo, p.base_branch, p.status);
|
|
506
506
|
}
|
|
507
|
-
for (const f of ["041_inter_epic_plan_deps.sql", "045_epic_set_admission_staging.sql"]) {
|
|
507
|
+
for (const f of ["041_inter_epic_plan_deps.sql", "045_epic_set_admission_staging.sql", "124_admitted_epics_auto_resolve.sql"]) {
|
|
508
508
|
db.exec(readFileSync(fileURLToPath(new URL(`../db/migrations/${f}`, import.meta.url)), "utf8"));
|
|
509
509
|
}
|
|
510
510
|
const q = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
@@ -51,6 +51,8 @@ interface EpicMember {
|
|
|
51
51
|
baseBranch: string;
|
|
52
52
|
allowSharedBase: boolean;
|
|
53
53
|
confirmDefaultBase: boolean;
|
|
54
|
+
/** Escalation-steward opt-in for this epic (issue #889) — carried through staging to `startPlan`. */
|
|
55
|
+
autoResolveEscalations: boolean;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
@@ -104,6 +106,7 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
|
104
106
|
baseBranch: typeof m.baseBranch === "string" ? m.baseBranch : "",
|
|
105
107
|
allowSharedBase: m.allowSharedBase === true,
|
|
106
108
|
confirmDefaultBase: m.confirmDefaultBase === true,
|
|
109
|
+
autoResolveEscalations: m.autoResolveEscalations === true,
|
|
107
110
|
});
|
|
108
111
|
planKeys.push(parsed.planKey);
|
|
109
112
|
}
|
|
@@ -125,7 +128,7 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
|
125
128
|
// persisted. `selfPlanKey` excludes the epic's own active row so an idempotent re-submit does not
|
|
126
129
|
// 409 against itself.
|
|
127
130
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
128
|
-
const admitted: { parsed: ParsedIssue; baseBranch: string }[] = [];
|
|
131
|
+
const admitted: { parsed: ParsedIssue; baseBranch: string; autoResolveEscalations: boolean }[] = [];
|
|
129
132
|
// Intra-set shared-base guard: admitPlan's rule 4 only inspects DURABLE `plans` rows, and S2
|
|
130
133
|
// materializes none, so two members of THIS set reaching for the same custom integration branch
|
|
131
134
|
// would both slip past it and silently defeat ADR 0003 rule 4. Track each admitted member's
|
|
@@ -151,7 +154,7 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
|
151
154
|
if (claimed) claimed.add(normalizedBase);
|
|
152
155
|
else claimedBases.set(member.parsed.repo, new Set([normalizedBase]));
|
|
153
156
|
}
|
|
154
|
-
admitted.push({ parsed: member.parsed, baseBranch: normalizedBase });
|
|
157
|
+
admitted.push({ parsed: member.parsed, baseBranch: normalizedBase, autoResolveEscalations: member.autoResolveEscalations });
|
|
155
158
|
} catch (err) {
|
|
156
159
|
const mapped = admitPlanErrorResponse(err);
|
|
157
160
|
if (mapped) {
|
|
@@ -183,6 +186,9 @@ export default defineOperation("startEpicSet", async ({ body }, app) => {
|
|
|
183
186
|
issue_number: a.parsed.number,
|
|
184
187
|
issue_url: a.parsed.url,
|
|
185
188
|
base_branch: a.baseBranch,
|
|
189
|
+
// Carry the per-epic steward opt-in (issue #889) into staging so the lowering forwards it to
|
|
190
|
+
// `startPlan` — without it a batch-admitted epic stayed opted out despite the caller's choice.
|
|
191
|
+
auto_resolve_escalations: a.autoResolveEscalations ? 1 : 0,
|
|
186
192
|
});
|
|
187
193
|
}
|
|
188
194
|
for (const edge of edges) {
|
|
@@ -118,6 +118,9 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
118
118
|
const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
|
|
119
119
|
// Human approval gates the merge-loop, so it is moot (pinned off) without auto-merge (issue #826).
|
|
120
120
|
const humanApproval = autoMerge && "humanApproval" in body && body.humanApproval === true;
|
|
121
|
+
// Opt-in escalation steward (issue #889): only when explicitly requested does this run's
|
|
122
|
+
// escalations become eligible for steward auto-resolution. Default off.
|
|
123
|
+
const autoResolveEscalations = "autoResolveEscalations" in body && body.autoResolveEscalations === true;
|
|
121
124
|
// Optional operator steering threaded to the implementation agent's prompt. Empty/whitespace is
|
|
122
125
|
// normalized to null downstream (startFeature) so it never appends an empty instruction block.
|
|
123
126
|
const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
|
|
@@ -179,6 +182,7 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
179
182
|
customInstructions,
|
|
180
183
|
{ probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
|
|
181
184
|
humanApproval,
|
|
185
|
+
autoResolveEscalations,
|
|
182
186
|
);
|
|
183
187
|
app.log.info("feature run intake", {
|
|
184
188
|
featureKey: parsed.planKey,
|
|
@@ -186,6 +190,7 @@ export default defineOperation("startFeature", async ({ body }, app) => {
|
|
|
186
190
|
converge,
|
|
187
191
|
autoMerge,
|
|
188
192
|
humanApproval,
|
|
193
|
+
autoResolveEscalations,
|
|
189
194
|
hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
|
|
190
195
|
readinessProbes: readiness.probes.length,
|
|
191
196
|
outcome: result.outcome,
|
|
@@ -49,7 +49,9 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
|
|
|
49
49
|
}
|
|
50
50
|
throw err;
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
// Opt-in escalation steward (issue #889): propagate to every child the epic spawns.
|
|
53
|
+
const autoResolveEscalations = "autoResolveEscalations" in body && body.autoResolveEscalations === true;
|
|
54
|
+
const result = await startPlan(app.data, app.engine, parsed, normalizedBase, { autoResolveEscalations });
|
|
53
55
|
const alreadyRunning = "alreadyRunning" in result && result.alreadyRunning === true;
|
|
54
56
|
app.log.info("plan fan-out started", {
|
|
55
57
|
planKey: parsed.planKey,
|
|
@@ -58,6 +60,7 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
|
|
|
58
60
|
// request — not the effective base — to keep the log honest.
|
|
59
61
|
requestedBaseBranch: normalizedBase,
|
|
60
62
|
alreadyRunning,
|
|
63
|
+
autoResolveEscalations,
|
|
61
64
|
});
|
|
62
65
|
return { status: 202, body: result };
|
|
63
66
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.203.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/_nav.json
CHANGED
|
@@ -24,6 +24,18 @@
|
|
|
24
24
|
"hideWhenZero": true
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
|
+
{
|
|
28
|
+
"label": "Auto-resolutions",
|
|
29
|
+
"page": "auto-resolutions",
|
|
30
|
+
"badge": {
|
|
31
|
+
"source": "app",
|
|
32
|
+
"table": "escalation_auto_resolutions__read_model",
|
|
33
|
+
"filter": [{ "field": "review_state", "eq": "pending" }],
|
|
34
|
+
"tone": "warn",
|
|
35
|
+
"refreshMs": 5000,
|
|
36
|
+
"hideWhenZero": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
27
39
|
{ "label": "Cockpit", "page": "cockpit" },
|
|
28
40
|
{ "label": "Board", "page": "board" },
|
|
29
41
|
{ "label": "Velocity", "page": "velocity" }
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"title": "Auto-resolutions",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"title": "Nano Workforce",
|
|
11
|
+
"items": [
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Connect over MCP",
|
|
18
|
+
"page": "mcp"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Lineage",
|
|
22
|
+
"page": "lineage"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Convergence",
|
|
26
|
+
"page": "home"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Epics",
|
|
30
|
+
"page": "epic"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Feature",
|
|
34
|
+
"page": "feature"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"label": "Delivery Graphs",
|
|
38
|
+
"page": "delivery-graphs"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"label": "Tasks",
|
|
42
|
+
"page": "tasks",
|
|
43
|
+
"badge": {
|
|
44
|
+
"source": "app",
|
|
45
|
+
"table": "user_tasks",
|
|
46
|
+
"filter": [],
|
|
47
|
+
"tone": "danger",
|
|
48
|
+
"refreshMs": 5000,
|
|
49
|
+
"hideWhenZero": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"label": "Auto-resolutions",
|
|
54
|
+
"page": "auto-resolutions",
|
|
55
|
+
"badge": {
|
|
56
|
+
"source": "app",
|
|
57
|
+
"table": "escalation_auto_resolutions__read_model",
|
|
58
|
+
"filter": [
|
|
59
|
+
{
|
|
60
|
+
"field": "review_state",
|
|
61
|
+
"eq": "pending"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"tone": "warn",
|
|
65
|
+
"refreshMs": 5000,
|
|
66
|
+
"hideWhenZero": true
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"label": "Cockpit",
|
|
71
|
+
"page": "cockpit"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"label": "Board",
|
|
75
|
+
"page": "board"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"label": "Velocity",
|
|
79
|
+
"page": "velocity"
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"sticky": true
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"type": "text",
|
|
87
|
+
"id": "title",
|
|
88
|
+
"props": { "text": "Auto-resolutions", "variant": "heading" }
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"type": "text",
|
|
92
|
+
"id": "subtitle",
|
|
93
|
+
"props": {
|
|
94
|
+
"text": "Every escalation the opt-in escalation steward (issue #889) resolved or abstained from, as a durable, reviewable record. The default list shows pending records — examine one, then Dismiss it (reviewed, no action) or Intervene (mark it and record guidance for stepping in after the fact). Abstentions carry their own badge. A record is never a hidden machine decision: it is attributed to the steward agent, never to a human.",
|
|
95
|
+
"variant": "sub"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"type": "dataGrid",
|
|
100
|
+
"id": "auto-resolutions",
|
|
101
|
+
"props": {
|
|
102
|
+
"title": "Auto-resolutions",
|
|
103
|
+
"rowKey": "id",
|
|
104
|
+
"refreshMs": 5000,
|
|
105
|
+
"data": {
|
|
106
|
+
"kind": "datasource",
|
|
107
|
+
"source": "app",
|
|
108
|
+
"table": "escalation_auto_resolutions__read_model",
|
|
109
|
+
"orderBy": { "field": "created_at", "dir": "desc" }
|
|
110
|
+
},
|
|
111
|
+
"tabs": [
|
|
112
|
+
{ "label": "Pending", "filter": [{ "field": "review_state", "in": ["pending"] }] },
|
|
113
|
+
{ "label": "Reviewed", "filter": [{ "field": "review_state", "in": ["dismissed", "intervened"] }] },
|
|
114
|
+
{ "label": "All", "filter": [] }
|
|
115
|
+
],
|
|
116
|
+
"columns": [
|
|
117
|
+
{ "field": "id", "header": "ID" },
|
|
118
|
+
{ "field": "outcome", "header": "Outcome", "badge": true },
|
|
119
|
+
{ "field": "selected_option", "header": "Chose" },
|
|
120
|
+
{ "field": "escalation_kind", "header": "Kind" },
|
|
121
|
+
{
|
|
122
|
+
"field": "subject_key",
|
|
123
|
+
"template": "{{subject_key}}",
|
|
124
|
+
"header": "Subject",
|
|
125
|
+
"truncate": true,
|
|
126
|
+
"linkField": "subject_url"
|
|
127
|
+
},
|
|
128
|
+
{ "field": "rationale", "header": "Rationale", "truncate": true },
|
|
129
|
+
{ "field": "worker", "header": "Worker" },
|
|
130
|
+
{ "field": "process_key", "header": "Agent", "template": "Transcript", "truncate": true, "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
|
|
131
|
+
{ "field": "process_instance_key", "header": "Engine instance", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_instance_key" } },
|
|
132
|
+
{ "field": "created_at", "header": "When", "format": "datetime" },
|
|
133
|
+
{ "field": "review_state", "header": "Review", "badge": true }
|
|
134
|
+
],
|
|
135
|
+
"rowActions": [
|
|
136
|
+
{
|
|
137
|
+
"label": "Dismiss",
|
|
138
|
+
"confirm": "Dismiss this auto-resolution (reviewed, no action)?",
|
|
139
|
+
"showWhenField": "pending_review",
|
|
140
|
+
"action": {
|
|
141
|
+
"path": "/app/api/auto-resolutions/review",
|
|
142
|
+
"body": { "id": "{{row.id}}", "action": "dismiss" }
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"label": "Intervene",
|
|
147
|
+
"confirm": "Mark this auto-resolution as intervened (you are stepping in after the fact)?",
|
|
148
|
+
"showWhenField": "pending_review",
|
|
149
|
+
"action": {
|
|
150
|
+
"path": "/app/api/auto-resolutions/review",
|
|
151
|
+
"body": { "id": "{{row.id}}", "action": "intervene" }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
"detail": {
|
|
156
|
+
"linkField": "subject_url",
|
|
157
|
+
"fields": [
|
|
158
|
+
{ "field": "id", "label": "Record id" },
|
|
159
|
+
{ "field": "question", "label": "Question" },
|
|
160
|
+
{ "field": "selected_option", "label": "Selected option" },
|
|
161
|
+
{ "field": "options", "label": "Options offered" },
|
|
162
|
+
{ "field": "submitted_variables", "label": "Submitted variables" },
|
|
163
|
+
{ "field": "rationale", "label": "Rationale" },
|
|
164
|
+
{ "field": "evidence", "label": "Evidence" },
|
|
165
|
+
{ "field": "subject_key", "label": "Subject" },
|
|
166
|
+
{ "field": "process_instance_key", "label": "Process instance" },
|
|
167
|
+
{ "field": "steward_instance_key", "label": "Steward instance" },
|
|
168
|
+
{ "field": "agent_job_key", "label": "Agent job" },
|
|
169
|
+
{ "field": "review_note", "label": "Review note" }
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"type": "actionForm",
|
|
176
|
+
"id": "intervene-with-note",
|
|
177
|
+
"props": {
|
|
178
|
+
"title": "Intervene with guidance",
|
|
179
|
+
"submitLabel": "Record intervention",
|
|
180
|
+
"fields": [
|
|
181
|
+
{ "key": "id", "label": "Record id (the pending auto-resolution to intervene on)", "type": "number", "required": true },
|
|
182
|
+
{ "key": "note", "label": "Guidance for stepping in (recorded as the review note)", "type": "text" }
|
|
183
|
+
],
|
|
184
|
+
"action": {
|
|
185
|
+
"path": "/app/api/auto-resolutions/review",
|
|
186
|
+
"body": { "id": "{{form.id}}", "action": "intervene", "note": "{{form.note}}" }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
}
|
package/pages/board.page.json
CHANGED
|
@@ -49,6 +49,23 @@
|
|
|
49
49
|
"hideWhenZero": true
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
|
+
{
|
|
53
|
+
"label": "Auto-resolutions",
|
|
54
|
+
"page": "auto-resolutions",
|
|
55
|
+
"badge": {
|
|
56
|
+
"source": "app",
|
|
57
|
+
"table": "escalation_auto_resolutions__read_model",
|
|
58
|
+
"filter": [
|
|
59
|
+
{
|
|
60
|
+
"field": "review_state",
|
|
61
|
+
"eq": "pending"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"tone": "warn",
|
|
65
|
+
"refreshMs": 5000,
|
|
66
|
+
"hideWhenZero": true
|
|
67
|
+
}
|
|
68
|
+
},
|
|
52
69
|
{
|
|
53
70
|
"label": "Cockpit",
|
|
54
71
|
"page": "cockpit"
|