@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// End-to-end proof for the user-task + form spine (epic #156, slice U0 — the keystone).
|
|
2
|
+
//
|
|
3
|
+
// Boots this whole Urban app in-process against the WASM engine and a virtual clock via
|
|
4
|
+
// `bootTestApp`, deploys the throwaway `spine-demo` process (a native `userTask` linked to
|
|
5
|
+
// `spine-demo.form`, whose completion drives a data-based decision gateway), and proves the
|
|
6
|
+
// round-trip every later escalation slice builds on:
|
|
7
|
+
//
|
|
8
|
+
// start instance → the task is listed via the `taskInbox` surface route
|
|
9
|
+
// (GET /tasks/api/tasks) → complete it with the typed form field via POST /tasks/api/complete
|
|
10
|
+
// → the process resumes, and the TYPED variable routes the decision gateway before the instance
|
|
11
|
+
// COMPLETES.
|
|
12
|
+
//
|
|
13
|
+
// The gateway is deliberate: a bare "the token advanced" assertion cannot tell a real typed
|
|
14
|
+
// submission apart from an empty one (the WASM engine folds a completed instance's variables away,
|
|
15
|
+
// so they are not observable post-completion). Routing the resume through a FEEL condition on the
|
|
16
|
+
// form's `decision` field makes "resumes WITH those variables" falsifiable — an empty/wrong value
|
|
17
|
+
// would take the gateway's default (reject) flow instead of the approve flow this test asserts. This
|
|
18
|
+
// mirrors the answer/abandon gateway the escalation slices (U2/U3) build on the same spine.
|
|
19
|
+
//
|
|
20
|
+
// Network isolation mirrors the sibling convergence e2e: the app's GitHub transport is forced to
|
|
21
|
+
// `token` mode with no token, so any best-effort GitHub read short-circuits instead of reaching out.
|
|
22
|
+
//
|
|
23
|
+
// Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
|
|
24
|
+
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { dirname, join, resolve } from "node:path";
|
|
29
|
+
import { after, before, describe, test } from "node:test";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
32
|
+
|
|
33
|
+
// The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + the
|
|
34
|
+
// resources/processes + resources/forms it deploys live.
|
|
35
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
36
|
+
|
|
37
|
+
// Provision the app's SQLite in a throwaway temp dir so the test never touches (or leaks into) the
|
|
38
|
+
// repo's real ./app.db, and every run starts from a freshly-migrated, empty schema.
|
|
39
|
+
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u0-"));
|
|
40
|
+
|
|
41
|
+
// Force the app fully offline (github.ts reads process.env directly, not the harness env overlay):
|
|
42
|
+
// `token` mode with no GITHUB_TOKEN means every best-effort GitHub read short-circuits to null.
|
|
43
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
44
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
45
|
+
GITHUB_TOKEN: "",
|
|
46
|
+
};
|
|
47
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
48
|
+
|
|
49
|
+
// The harness `env` overlay drives the runtime's `${NANO_APP_DB_URL}` resolution.
|
|
50
|
+
const HARNESS_ENV = {
|
|
51
|
+
NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
interface InboxTask {
|
|
55
|
+
userTaskKey: string;
|
|
56
|
+
elementId?: string;
|
|
57
|
+
variables?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TakenFlow {
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The engine snapshot's cumulative taken sequence flows, as `from->to` strings. With a single
|
|
66
|
+
* instance in play this is exactly that instance's routing history. */
|
|
67
|
+
function takenFlows(app: TestApp): string[] {
|
|
68
|
+
const snapshot = app.snapshot();
|
|
69
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
70
|
+
return flows
|
|
71
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
72
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("nano-workforce user-task spine (U0 keystone)", () => {
|
|
76
|
+
let app: TestApp;
|
|
77
|
+
|
|
78
|
+
before(async () => {
|
|
79
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
80
|
+
savedEnv.set(k, process.env[k]);
|
|
81
|
+
process.env[k] = v;
|
|
82
|
+
}
|
|
83
|
+
app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
after(async () => {
|
|
87
|
+
await app?.stop();
|
|
88
|
+
for (const [k, v] of savedEnv) {
|
|
89
|
+
if (v === undefined) delete process.env[k];
|
|
90
|
+
else process.env[k] = v;
|
|
91
|
+
}
|
|
92
|
+
rmSync(DB_DIR, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a userTask bearing a linked .form round-trips list → complete → resume with typed vars", async () => {
|
|
96
|
+
// Boot deploys every model under `models` — including the throwaway spine-demo.bpmn and its
|
|
97
|
+
// linked spine-demo.form. Start one instance of the spine process.
|
|
98
|
+
const created = await app.engine.createInstance({ processDefinitionId: "spine-demo" });
|
|
99
|
+
const processInstanceKey = created.processInstanceKey;
|
|
100
|
+
assert.ok(processInstanceKey, "starting spine-demo returns a process instance key");
|
|
101
|
+
|
|
102
|
+
// The instance parks on the native userTask. It is visible through the `taskInbox` surface's
|
|
103
|
+
// JSON route (GET /tasks/api/tasks) — the manifest-enabled surface this slice lands.
|
|
104
|
+
const listed = await app.callRoute<InboxTask[]>({
|
|
105
|
+
method: "GET",
|
|
106
|
+
path: "/tasks/api/tasks",
|
|
107
|
+
query: { processInstanceKey },
|
|
108
|
+
});
|
|
109
|
+
assert.equal(listed.status, 200, "the taskInbox surface serves the task list");
|
|
110
|
+
assert.equal(listed.body.length, 1, "exactly the one spine userTask is open");
|
|
111
|
+
const task = listed.body[0];
|
|
112
|
+
assert.equal(task.elementId, "decide", "the open task is the spine's `decide` userTask");
|
|
113
|
+
assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
|
|
114
|
+
|
|
115
|
+
// Complete it through the surface's completion route (POST /tasks/api/complete) with exactly the
|
|
116
|
+
// typed field the linked .form declares (`decision`). This is the list → render → complete path
|
|
117
|
+
// an operator drives through the inbox.
|
|
118
|
+
const completed = await app.callRoute<{ ok: boolean }>({
|
|
119
|
+
method: "POST",
|
|
120
|
+
path: "/tasks/api/complete",
|
|
121
|
+
body: JSON.stringify({ userTaskKey: task.userTaskKey, variables: { decision: "approve" } }),
|
|
122
|
+
});
|
|
123
|
+
assert.equal(completed.status, 200, "the completion route accepts the typed form submission");
|
|
124
|
+
assert.equal(completed.body.ok, true, "the userTask was completed");
|
|
125
|
+
|
|
126
|
+
// The typed variable resumed the token through the decision gateway: `decision = "approve"`
|
|
127
|
+
// satisfied the FEEL condition, so the token took the approve flow (NOT the default reject flow).
|
|
128
|
+
// This is the falsifiable core of "resumes WITH those variables" — an empty/wrong value would
|
|
129
|
+
// have fallen through to `end-rejected`.
|
|
130
|
+
const flows = takenFlows(app);
|
|
131
|
+
assert.ok(
|
|
132
|
+
flows.includes("gw-decision->end-approved"),
|
|
133
|
+
`the typed decision routed to the approve end (flows: ${flows.join(", ")})`,
|
|
134
|
+
);
|
|
135
|
+
assert.ok(
|
|
136
|
+
!flows.includes("gw-decision->end-rejected"),
|
|
137
|
+
`the default reject flow was NOT taken (flows: ${flows.join(", ")})`,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// The token advanced start → userTask → gateway → end: the process resumes and reaches COMPLETED,
|
|
141
|
+
// with no open task left behind.
|
|
142
|
+
const instances = await app.engine.searchProcessInstances({
|
|
143
|
+
processInstanceKeys: [processInstanceKey],
|
|
144
|
+
});
|
|
145
|
+
assert.equal(instances.length, 1, "the spine instance is still resolvable");
|
|
146
|
+
assert.equal(instances[0].state, "COMPLETED", "the process resumed and completed");
|
|
147
|
+
|
|
148
|
+
const remaining = await app.callRoute<InboxTask[]>({
|
|
149
|
+
method: "GET",
|
|
150
|
+
path: "/tasks/api/tasks",
|
|
151
|
+
query: { processInstanceKey },
|
|
152
|
+
});
|
|
153
|
+
assert.equal(remaining.body.length, 0, "the completed task is no longer open");
|
|
154
|
+
});
|
|
155
|
+
});
|
package/nano.app.json
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"processes": [
|
|
9
9
|
"resources/processes/*.bpmn"
|
|
10
10
|
],
|
|
11
|
+
"forms": [
|
|
12
|
+
"resources/forms/*.form"
|
|
13
|
+
],
|
|
11
14
|
"templates": [
|
|
12
15
|
"prompts/*.md"
|
|
13
16
|
]
|
|
@@ -39,9 +42,7 @@
|
|
|
39
42
|
],
|
|
40
43
|
"onTerminated": {
|
|
41
44
|
"set": {
|
|
42
|
-
"status": "abandoned"
|
|
43
|
-
"open_escalation_id": null,
|
|
44
|
-
"open_escalation_question": null
|
|
45
|
+
"status": "abandoned"
|
|
45
46
|
}
|
|
46
47
|
},
|
|
47
48
|
"pollMs": 5000
|
|
@@ -60,6 +61,20 @@
|
|
|
60
61
|
}
|
|
61
62
|
},
|
|
62
63
|
"pollMs": 5000
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"table": "feature_runs",
|
|
67
|
+
"keyField": "process_key",
|
|
68
|
+
"statusField": "status",
|
|
69
|
+
"activeStatuses": [
|
|
70
|
+
"running"
|
|
71
|
+
],
|
|
72
|
+
"onTerminated": {
|
|
73
|
+
"set": {
|
|
74
|
+
"status": "abandoned"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"pollMs": 5000
|
|
63
78
|
}
|
|
64
79
|
],
|
|
65
80
|
"workers": [
|
|
@@ -75,6 +90,10 @@
|
|
|
75
90
|
"taskType": "pr.persist-escalation",
|
|
76
91
|
"handler": "workers/persist-escalation/worker.ts"
|
|
77
92
|
},
|
|
93
|
+
{
|
|
94
|
+
"taskType": "pr.answer-escalation",
|
|
95
|
+
"handler": "workers/answer-escalation/worker.ts"
|
|
96
|
+
},
|
|
78
97
|
{
|
|
79
98
|
"taskType": "pr.finalize",
|
|
80
99
|
"handler": "workers/finalize/worker.ts"
|
|
@@ -112,20 +131,24 @@
|
|
|
112
131
|
"handler": "workers/record-wave/worker.ts"
|
|
113
132
|
},
|
|
114
133
|
{
|
|
115
|
-
"taskType": "pr.record-
|
|
116
|
-
"handler": "workers/record-
|
|
134
|
+
"taskType": "pr.record-feature",
|
|
135
|
+
"handler": "workers/record-feature/worker.ts"
|
|
117
136
|
},
|
|
118
137
|
{
|
|
119
|
-
"taskType": "pr.
|
|
120
|
-
"handler": "workers/
|
|
138
|
+
"taskType": "pr.converge-feature",
|
|
139
|
+
"handler": "workers/converge-feature/worker.ts"
|
|
121
140
|
},
|
|
122
141
|
{
|
|
123
|
-
"taskType": "pr.
|
|
124
|
-
"handler": "workers/
|
|
142
|
+
"taskType": "pr.record-trial-merge",
|
|
143
|
+
"handler": "workers/record-trial-merge/worker.ts"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"taskType": "pr.resolve-trial-attention",
|
|
147
|
+
"handler": "workers/resolve-trial-attention/worker.ts"
|
|
125
148
|
},
|
|
126
149
|
{
|
|
127
|
-
"taskType": "pr.
|
|
128
|
-
"handler": "workers/
|
|
150
|
+
"taskType": "pr.record-results",
|
|
151
|
+
"handler": "workers/record-results/worker.ts"
|
|
129
152
|
},
|
|
130
153
|
{
|
|
131
154
|
"taskType": "pr.retro-gather",
|
|
@@ -147,6 +170,9 @@
|
|
|
147
170
|
"senior:retro"
|
|
148
171
|
],
|
|
149
172
|
"surfaces": {
|
|
173
|
+
"taskInbox": {
|
|
174
|
+
"enabled": true
|
|
175
|
+
},
|
|
150
176
|
"pages": {
|
|
151
177
|
"enabled": true,
|
|
152
178
|
"pagesDir": "pages",
|
package/openapi.yaml
CHANGED
|
@@ -306,6 +306,19 @@ components:
|
|
|
306
306
|
alreadyRunning:
|
|
307
307
|
type: boolean
|
|
308
308
|
description: True when a non-terminal plan for this issue already exists; no new instance was started.
|
|
309
|
+
StartFeatureResult:
|
|
310
|
+
type: object
|
|
311
|
+
required:
|
|
312
|
+
- featureKey
|
|
313
|
+
properties:
|
|
314
|
+
featureKey:
|
|
315
|
+
type: string
|
|
316
|
+
processKey:
|
|
317
|
+
type: string
|
|
318
|
+
nullable: true
|
|
319
|
+
alreadyRunning:
|
|
320
|
+
type: boolean
|
|
321
|
+
description: True when a non-terminal feature run for this issue already exists; no new instance was started.
|
|
309
322
|
ConvergenceStart:
|
|
310
323
|
description: The start-convergence request body. Names the target PR by EXACTLY ONE of `pr`
|
|
311
324
|
(an `owner/repo#123` reference) or `url` (a bare PR URL) — never both, never neither — with
|
|
@@ -444,78 +457,144 @@ components:
|
|
|
444
457
|
Accepted by the schema today but currently has no runtime effect. When implemented it
|
|
445
458
|
will be the required acknowledgement when `baseBranch` names the repository default
|
|
446
459
|
branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
447
|
-
|
|
460
|
+
FeatureStart:
|
|
461
|
+
description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
|
|
462
|
+
by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
|
|
463
|
+
REQUIRED `baseBranch` (ADR 0003, same admission as the epic path), and the two optional
|
|
464
|
+
follow-on knobs `converge` / `autoMerge`. Modeled as `oneOf` named variants (Camunda REST v2
|
|
465
|
+
pattern) so an ambiguous or empty target is a 400 at the edge.
|
|
466
|
+
oneOf:
|
|
467
|
+
- $ref: "#/components/schemas/FeatureStartByIssue"
|
|
468
|
+
- $ref: "#/components/schemas/FeatureStartByUrl"
|
|
469
|
+
FeatureStartByIssue:
|
|
448
470
|
type: object
|
|
449
|
-
|
|
450
|
-
name; `ok` is always present.
|
|
451
|
-
additionalProperties: true
|
|
471
|
+
additionalProperties: false
|
|
452
472
|
required:
|
|
453
|
-
-
|
|
473
|
+
- issue
|
|
474
|
+
- baseBranch
|
|
454
475
|
properties:
|
|
455
|
-
|
|
476
|
+
issue:
|
|
477
|
+
type: string
|
|
478
|
+
description: "Issue reference: owner/repo#123."
|
|
479
|
+
baseBranch:
|
|
480
|
+
type: string
|
|
481
|
+
minLength: 1
|
|
482
|
+
maxLength: 255
|
|
483
|
+
pattern: '\S'
|
|
484
|
+
description: >-
|
|
485
|
+
REQUIRED target branch the agent branches off and opens its PR against. Admitted through
|
|
486
|
+
the same ADR 0003 policy as the epic path (`admitPlan`): a missing `epic/*` base is
|
|
487
|
+
auto-created off default HEAD; a non-`epic/*` base must already exist; naming the default
|
|
488
|
+
branch requires `confirmDefaultBase`.
|
|
489
|
+
converge:
|
|
456
490
|
type: boolean
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
491
|
+
description: >-
|
|
492
|
+
When true, hand the opened PR to the convergence loop (review rounds) as a follow-on step.
|
|
493
|
+
When false (default) the run ends at "PR raised, not reviewed".
|
|
494
|
+
autoMerge:
|
|
495
|
+
type: boolean
|
|
496
|
+
description: >-
|
|
497
|
+
When true (and `converge` is true), drive the merge-loop after convergence — mirrors
|
|
498
|
+
`convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`.
|
|
499
|
+
Moot when `converge` is false.
|
|
500
|
+
confirmDefaultBase:
|
|
501
|
+
type: boolean
|
|
502
|
+
description: >-
|
|
503
|
+
Acknowledge that `baseBranch` is the repository default branch (the PR would target it
|
|
504
|
+
directly). See `PlanStartByIssue.confirmDefaultBase`.
|
|
505
|
+
allowSharedBase:
|
|
506
|
+
type: boolean
|
|
507
|
+
description: >-
|
|
508
|
+
Opt in to sharing a custom integration base branch with another already-active epic. See
|
|
509
|
+
`PlanStartByIssue.allowSharedBase`.
|
|
510
|
+
FeatureStartByUrl:
|
|
467
511
|
type: object
|
|
468
512
|
additionalProperties: false
|
|
469
513
|
required:
|
|
470
|
-
-
|
|
471
|
-
-
|
|
514
|
+
- url
|
|
515
|
+
- baseBranch
|
|
472
516
|
properties:
|
|
473
|
-
|
|
517
|
+
url:
|
|
474
518
|
type: string
|
|
475
|
-
description:
|
|
476
|
-
|
|
519
|
+
description: A bare issue URL, when no `owner/repo#123` reference is supplied.
|
|
520
|
+
baseBranch:
|
|
477
521
|
type: string
|
|
478
522
|
minLength: 1
|
|
479
|
-
|
|
480
|
-
|
|
523
|
+
maxLength: 255
|
|
524
|
+
pattern: '\S'
|
|
525
|
+
description: >-
|
|
526
|
+
REQUIRED target branch the agent branches off and opens its PR against. See
|
|
527
|
+
`FeatureStartByIssue.baseBranch`.
|
|
528
|
+
converge:
|
|
529
|
+
type: boolean
|
|
530
|
+
description: When true, hand the opened PR to the convergence loop. See `FeatureStartByIssue.converge`.
|
|
531
|
+
autoMerge:
|
|
532
|
+
type: boolean
|
|
533
|
+
description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
|
|
534
|
+
confirmDefaultBase:
|
|
535
|
+
type: boolean
|
|
536
|
+
description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
537
|
+
allowSharedBase:
|
|
538
|
+
type: boolean
|
|
539
|
+
description: Share a custom integration base with another active epic. See `PlanStartByIssue.allowSharedBase`.
|
|
540
|
+
MessageResult:
|
|
541
|
+
type: object
|
|
542
|
+
description: The result of publishing a message / answering an escalation. Shape varies by message
|
|
543
|
+
name; `ok` is always present.
|
|
544
|
+
additionalProperties: true
|
|
545
|
+
required:
|
|
546
|
+
- ok
|
|
547
|
+
properties:
|
|
548
|
+
ok:
|
|
549
|
+
type: boolean
|
|
550
|
+
AgentCompleteRequest:
|
|
481
551
|
type: object
|
|
482
552
|
additionalProperties: false
|
|
553
|
+
description: >-
|
|
554
|
+
Complete an escalation user task AS AN AGENT (epic #156 / ADR 0046). The agent submits the
|
|
555
|
+
SAME typed form variables a human would through the task inbox; the host routes them through
|
|
556
|
+
the one canonical attributed completer, records the agent's identity for the audit trail, and
|
|
557
|
+
resumes the process. Only the migrated escalation user tasks may be completed this way.
|
|
483
558
|
required:
|
|
484
|
-
-
|
|
485
|
-
-
|
|
486
|
-
-
|
|
559
|
+
- userTaskKey
|
|
560
|
+
- agentId
|
|
561
|
+
- variables
|
|
487
562
|
properties:
|
|
488
|
-
|
|
489
|
-
type: string
|
|
490
|
-
description: Plan reference (owner/repo#N); combined with `task` to derive the corrKey.
|
|
491
|
-
task:
|
|
563
|
+
userTaskKey:
|
|
492
564
|
type: string
|
|
493
|
-
|
|
494
|
-
|
|
565
|
+
minLength: 1
|
|
566
|
+
description: The engine user-task key of the parked escalation task (from GET /tasks/api/tasks).
|
|
567
|
+
agentId:
|
|
495
568
|
type: string
|
|
496
569
|
minLength: 1
|
|
497
|
-
description: The
|
|
498
|
-
|
|
570
|
+
description: The completing agent's identity (ADR 0046), recorded for attribution.
|
|
571
|
+
variables:
|
|
572
|
+
type: object
|
|
573
|
+
additionalProperties: true
|
|
574
|
+
description: >-
|
|
575
|
+
The typed form completion variables — the SAME shape a human submits (e.g.
|
|
576
|
+
`{ resolution, answer }`, `{ directive, notes }`, `{ action, notes }`, or `{ answer }`).
|
|
577
|
+
RevertCompletionRequest:
|
|
499
578
|
type: object
|
|
500
579
|
additionalProperties: false
|
|
580
|
+
description: >-
|
|
581
|
+
Revert/override an agent escalation completion (the reversibility guarantee of ADR 0046). A
|
|
582
|
+
human marks a reversible agent completion reverted so the agent's answer is no longer treated
|
|
583
|
+
as authoritative; the reverter's identity + timestamp are recorded.
|
|
501
584
|
required:
|
|
502
|
-
-
|
|
503
|
-
-
|
|
585
|
+
- completionId
|
|
586
|
+
- reverterId
|
|
504
587
|
properties:
|
|
505
|
-
|
|
506
|
-
type:
|
|
507
|
-
description:
|
|
508
|
-
|
|
588
|
+
completionId:
|
|
589
|
+
type: integer
|
|
590
|
+
description: The `task_completions` id returned when the agent completion was recorded.
|
|
591
|
+
reverterId:
|
|
509
592
|
type: string
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
server-side, see `parsePlanEscalationDirective`).
|
|
513
|
-
`proceed` dispatches the current unapproved plan as an explicit human override;
|
|
514
|
-
`revise` loops back to the planner with the note folded into planFindings and a fresh
|
|
515
|
-
review budget.
|
|
593
|
+
minLength: 1
|
|
594
|
+
description: The human identity overriding the agent completion, recorded for the audit trail.
|
|
516
595
|
note:
|
|
517
596
|
type: string
|
|
518
|
-
description:
|
|
597
|
+
description: Optional corrective guidance from the human that overrides the agent's answer.
|
|
519
598
|
BlackboardEntry:
|
|
520
599
|
type: object
|
|
521
600
|
additionalProperties: false
|
|
@@ -767,12 +846,43 @@ paths:
|
|
|
767
846
|
application/json:
|
|
768
847
|
schema:
|
|
769
848
|
$ref: "#/components/schemas/ErrorBody"
|
|
849
|
+
/actions/start/feature:
|
|
850
|
+
post:
|
|
851
|
+
operationId: startFeature
|
|
852
|
+
summary: Register/refresh a feature-run aggregate (idempotent on featureKey) and start the single-issue run.
|
|
853
|
+
requestBody:
|
|
854
|
+
required: true
|
|
855
|
+
content:
|
|
856
|
+
application/json:
|
|
857
|
+
schema:
|
|
858
|
+
$ref: "#/components/schemas/FeatureStart"
|
|
859
|
+
responses:
|
|
860
|
+
"202":
|
|
861
|
+
description: The feature run was started (or was already running).
|
|
862
|
+
content:
|
|
863
|
+
application/json:
|
|
864
|
+
schema:
|
|
865
|
+
$ref: "#/components/schemas/StartFeatureResult"
|
|
866
|
+
"400":
|
|
867
|
+
description: The issue reference could not be parsed, or the base branch was rejected.
|
|
868
|
+
content:
|
|
869
|
+
application/json:
|
|
870
|
+
schema:
|
|
871
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
872
|
+
"409":
|
|
873
|
+
description: The base branch is already in use by another active epic.
|
|
874
|
+
content:
|
|
875
|
+
application/json:
|
|
876
|
+
schema:
|
|
877
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
770
878
|
/actions/message:
|
|
771
879
|
post:
|
|
772
880
|
operationId: postMessage
|
|
773
|
-
summary: Publish a message / answer an escalation. For escalation-answered
|
|
774
|
-
|
|
775
|
-
|
|
881
|
+
summary: "Publish a message / answer an escalation. For the merge-loop `escalation-answered`
|
|
882
|
+
name, runs the merge-loop escalation answer flow; otherwise a plain publishMessage. The
|
|
883
|
+
epic #156 escalation
|
|
884
|
+
kinds (task, plan-review, trial-merge, PR review-loop) are native user tasks answered via
|
|
885
|
+
the task inbox (POST /tasks/api/complete), not this route."
|
|
776
886
|
requestBody:
|
|
777
887
|
required: true
|
|
778
888
|
content:
|
|
@@ -795,13 +905,7 @@ paths:
|
|
|
795
905
|
properties:
|
|
796
906
|
answer:
|
|
797
907
|
type: string
|
|
798
|
-
|
|
799
|
-
type: string
|
|
800
|
-
description: >-
|
|
801
|
-
One of `proceed` or `revise` (case-insensitive; normalized to lowercase and
|
|
802
|
-
trimmed server-side, see `parsePlanEscalationDirective`).
|
|
803
|
-
note:
|
|
804
|
-
type: string
|
|
908
|
+
description: The operator's answer that resumes a parked merge-loop escalation.
|
|
805
909
|
responses:
|
|
806
910
|
"200":
|
|
807
911
|
description: The message was published (or the escalation answered).
|
|
@@ -821,11 +925,13 @@ paths:
|
|
|
821
925
|
application/json:
|
|
822
926
|
schema:
|
|
823
927
|
$ref: "#/components/schemas/MessageResult"
|
|
824
|
-
/hooks/
|
|
928
|
+
/hooks/agent-complete:
|
|
825
929
|
post:
|
|
826
|
-
operationId:
|
|
827
|
-
summary: "
|
|
828
|
-
|
|
930
|
+
operationId: agentCompleteEscalation
|
|
931
|
+
summary: "Complete an escalation user task as an agent assignee (ADR 0046). Reuses the same
|
|
932
|
+
`.form` contract + engine resume path a human uses; records the agent identity for
|
|
933
|
+
attribution. Optional shared-secret guard (x-hook-secret), enforced only when
|
|
934
|
+
NANO_PR_WEBHOOK_SECRET is set."
|
|
829
935
|
security:
|
|
830
936
|
- hookSecret: []
|
|
831
937
|
- {}
|
|
@@ -834,16 +940,16 @@ paths:
|
|
|
834
940
|
content:
|
|
835
941
|
application/json:
|
|
836
942
|
schema:
|
|
837
|
-
$ref: "#/components/schemas/
|
|
943
|
+
$ref: "#/components/schemas/AgentCompleteRequest"
|
|
838
944
|
responses:
|
|
839
945
|
"200":
|
|
840
|
-
description: The escalation
|
|
946
|
+
description: The agent completed the escalation task and the process resumed.
|
|
841
947
|
content:
|
|
842
948
|
application/json:
|
|
843
949
|
schema:
|
|
844
950
|
$ref: "#/components/schemas/MessageResult"
|
|
845
951
|
"400":
|
|
846
|
-
description: A required field was missing (
|
|
952
|
+
description: A required field was missing (userTaskKey, agentId, variables), or the target is not an escalation task.
|
|
847
953
|
content:
|
|
848
954
|
application/json:
|
|
849
955
|
schema:
|
|
@@ -855,16 +961,18 @@ paths:
|
|
|
855
961
|
schema:
|
|
856
962
|
$ref: "#/components/schemas/MessageResult"
|
|
857
963
|
"404":
|
|
858
|
-
description: No
|
|
964
|
+
description: No open escalation user task matches the userTaskKey.
|
|
859
965
|
content:
|
|
860
966
|
application/json:
|
|
861
967
|
schema:
|
|
862
968
|
$ref: "#/components/schemas/MessageResult"
|
|
863
|
-
/hooks/
|
|
969
|
+
/hooks/revert-completion:
|
|
864
970
|
post:
|
|
865
|
-
operationId:
|
|
866
|
-
summary: "
|
|
867
|
-
|
|
971
|
+
operationId: revertEscalationCompletion
|
|
972
|
+
summary: "Revert/override an agent escalation completion (ADR 0046 reversibility). A human marks
|
|
973
|
+
a reversible agent completion reverted so the agent's answer is no longer authoritative.
|
|
974
|
+
Optional shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is
|
|
975
|
+
set."
|
|
868
976
|
security:
|
|
869
977
|
- hookSecret: []
|
|
870
978
|
- {}
|
|
@@ -873,16 +981,16 @@ paths:
|
|
|
873
981
|
content:
|
|
874
982
|
application/json:
|
|
875
983
|
schema:
|
|
876
|
-
$ref: "#/components/schemas/
|
|
984
|
+
$ref: "#/components/schemas/RevertCompletionRequest"
|
|
877
985
|
responses:
|
|
878
986
|
"200":
|
|
879
|
-
description: The
|
|
987
|
+
description: The agent completion was reverted.
|
|
880
988
|
content:
|
|
881
989
|
application/json:
|
|
882
990
|
schema:
|
|
883
991
|
$ref: "#/components/schemas/MessageResult"
|
|
884
992
|
"400":
|
|
885
|
-
description: A required field was missing
|
|
993
|
+
description: A required field was missing, or the completion is not reversible / already reverted.
|
|
886
994
|
content:
|
|
887
995
|
application/json:
|
|
888
996
|
schema:
|
|
@@ -894,7 +1002,7 @@ paths:
|
|
|
894
1002
|
schema:
|
|
895
1003
|
$ref: "#/components/schemas/MessageResult"
|
|
896
1004
|
"404":
|
|
897
|
-
description: No
|
|
1005
|
+
description: No completion matches the completionId.
|
|
898
1006
|
content:
|
|
899
1007
|
application/json:
|
|
900
1008
|
schema:
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// POST /app/api/hooks/agent-complete → operationId `agentCompleteEscalation` (epic #156, slice U6;
|
|
2
|
+
// ADR 0046). Lets an AGENT assignee complete an escalation user task by submitting the SAME typed
|
|
3
|
+
// form variables a human would through the task inbox. The completion routes through the one
|
|
4
|
+
// canonical attributed completer (`completeEscalationAsAgent` → `completeUserTaskAttributed`), which
|
|
5
|
+
// records the agent's identity in the `task_completions` ledger (for attribution) and then resumes
|
|
6
|
+
// the process via `engine.completeUserTask` — the exact same resume path a human drives, no parallel
|
|
7
|
+
// lane. Optional shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is
|
|
8
|
+
// set, mirroring the other operator/webhook control surfaces.
|
|
9
|
+
//
|
|
10
|
+
// The runtime validates the body against openapi.yaml (`userTaskKey`, `agentId`, `variables` all
|
|
11
|
+
// required); this delegate narrows the validated shape and applies the shared-secret guard.
|
|
12
|
+
|
|
13
|
+
import { completeEscalationAsAgent } from "../app/agentCompletion.ts";
|
|
14
|
+
import { envVar } from "../app/version.ts";
|
|
15
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
16
|
+
|
|
17
|
+
const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
18
|
+
|
|
19
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
20
|
+
|
|
21
|
+
export default defineOperation("agentCompleteEscalation", async ({ req, body }, app) => {
|
|
22
|
+
if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
|
|
23
|
+
app.log.warn("agent-complete rejected: missing/invalid shared secret");
|
|
24
|
+
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
|
25
|
+
}
|
|
26
|
+
if (!body || typeof body !== "object") {
|
|
27
|
+
app.log.warn("agent-complete rejected: missing request body");
|
|
28
|
+
return { status: 400, body: { ok: false, error: "userTaskKey, agentId and variables are required" } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const userTaskKey = str(body.userTaskKey);
|
|
32
|
+
const agentId = str(body.agentId);
|
|
33
|
+
const variables = body.variables;
|
|
34
|
+
if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
|
|
35
|
+
if (!agentId) return { status: 400, body: { ok: false, error: "agentId is required" } };
|
|
36
|
+
if (!variables || typeof variables !== "object" || Array.isArray(variables)) {
|
|
37
|
+
return { status: 400, body: { ok: false, error: "variables must be an object" } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const r = await completeEscalationAsAgent(app.data, app.engine, {
|
|
41
|
+
userTaskKey,
|
|
42
|
+
agentId,
|
|
43
|
+
variables,
|
|
44
|
+
});
|
|
45
|
+
if (r.ok) {
|
|
46
|
+
app.log.info("agent completed escalation", { userTaskKey, agentId, elementId: r.elementId });
|
|
47
|
+
return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
|
|
48
|
+
}
|
|
49
|
+
// A non-escalation / missing-target is a client error; an unknown key is a 404.
|
|
50
|
+
const status = r.reason === "no open escalation task" ? 404 : 400;
|
|
51
|
+
app.log.warn("agent-complete: not completed", { userTaskKey, reason: r.reason });
|
|
52
|
+
return { status, body: { ok: false, error: r.reason } };
|
|
53
|
+
});
|