@nanobpm/nano-workforce 0.64.0 → 0.65.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 +7 -0
- package/app/agentCompletion.test.ts +51 -0
- package/app/agentCompletion.ts +50 -12
- package/app/feature.ts +80 -2
- package/app/featureEscalation.test.ts +180 -0
- package/app/service.ts +45 -1
- package/db/migrations/031_feature_escalation_surface.sql +28 -0
- package/e2e/feature-run.e2e.ts +58 -0
- package/nano.app.json +6 -1
- package/openapi.yaml +52 -0
- package/operations/answerFeatureEscalation.ts +68 -0
- package/package.json +1 -1
- package/pages/feature.page.json +39 -3
- package/pages/overview.page.json +37 -2
- package/resources/processes/feature.bpmn +79 -57
- package/scripts/pages-contract.test.ts +1 -1
- package/workers/record-feature/worker.ts +6 -0
- package/workers/record-feature-escalation/worker.test.ts +74 -0
- package/workers/record-feature-escalation/worker.ts +42 -0
package/openapi.yaml
CHANGED
|
@@ -925,6 +925,58 @@ paths:
|
|
|
925
925
|
application/json:
|
|
926
926
|
schema:
|
|
927
927
|
$ref: "#/components/schemas/MessageResult"
|
|
928
|
+
/actions/answer-escalation:
|
|
929
|
+
post:
|
|
930
|
+
operationId: answerFeatureEscalation
|
|
931
|
+
summary: "Answer a native feature-run escalation (issue #210). Completes the parked
|
|
932
|
+
`feature-escalation` user task with the operator's typed form variables (resolution=answer/abandon
|
|
933
|
+
+ guidance), driving the canonical attributed completer (completeUserTaskAttributed) — the same
|
|
934
|
+
resume path the task inbox uses, recording who answered. The poller then reconciles the run off
|
|
935
|
+
`escalated`. This is the nwf UI's answer affordance for a feature run parked on a human decision."
|
|
936
|
+
requestBody:
|
|
937
|
+
required: true
|
|
938
|
+
content:
|
|
939
|
+
application/json:
|
|
940
|
+
schema:
|
|
941
|
+
type: object
|
|
942
|
+
additionalProperties: false
|
|
943
|
+
required:
|
|
944
|
+
- userTaskKey
|
|
945
|
+
- resolution
|
|
946
|
+
properties:
|
|
947
|
+
userTaskKey:
|
|
948
|
+
type: string
|
|
949
|
+
minLength: 1
|
|
950
|
+
description: The parked `feature-escalation` user-task key (feature_runs.escalation_user_task_key).
|
|
951
|
+
resolution:
|
|
952
|
+
type: string
|
|
953
|
+
enum: [answer, abandon]
|
|
954
|
+
description: answer ⇒ re-dispatch the implement task with the guidance; abandon ⇒ give up on the task.
|
|
955
|
+
answer:
|
|
956
|
+
type: string
|
|
957
|
+
description: Guidance for the implementation agent. Required (non-blank) when resolution is answer.
|
|
958
|
+
operator:
|
|
959
|
+
type: string
|
|
960
|
+
description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
|
|
961
|
+
responses:
|
|
962
|
+
"200":
|
|
963
|
+
description: The escalation user task was completed and the process resumed.
|
|
964
|
+
content:
|
|
965
|
+
application/json:
|
|
966
|
+
schema:
|
|
967
|
+
$ref: "#/components/schemas/MessageResult"
|
|
968
|
+
"400":
|
|
969
|
+
description: A required field was missing/invalid, or the target is not an escalation task.
|
|
970
|
+
content:
|
|
971
|
+
application/json:
|
|
972
|
+
schema:
|
|
973
|
+
$ref: "#/components/schemas/MessageResult"
|
|
974
|
+
"404":
|
|
975
|
+
description: No open escalation user task matches the userTaskKey.
|
|
976
|
+
content:
|
|
977
|
+
application/json:
|
|
978
|
+
schema:
|
|
979
|
+
$ref: "#/components/schemas/MessageResult"
|
|
928
980
|
/hooks/agent-complete:
|
|
929
981
|
post:
|
|
930
982
|
operationId: agentCompleteEscalation
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// POST /app/api/actions/answer-escalation → operationId `answerFeatureEscalation` (issue #210).
|
|
2
|
+
// The nwf UI's answer affordance for a native feature-run escalation: an operator answers the parked
|
|
3
|
+
// `feature-escalation` user task (resolution=answer/abandon + optional guidance) directly from the
|
|
4
|
+
// Feature / Overview pages, instead of only from the generic task inbox.
|
|
5
|
+
//
|
|
6
|
+
// It routes through the ONE canonical attributed completer (`completeEscalationAsHuman` →
|
|
7
|
+
// `completeUserTaskAttributed`), so the completion uses the exact same typed `.form` variables and
|
|
8
|
+
// engine resume path a human drives from the task inbox — no parallel completion — while recording
|
|
9
|
+
// WHO answered in the `task_completions` ledger. The poller then reconciles the run off `escalated`
|
|
10
|
+
// (pollFeatureEscalations) once the task is gone.
|
|
11
|
+
//
|
|
12
|
+
// The runtime validates the body against openapi.yaml (`userTaskKey` + `resolution` required); this
|
|
13
|
+
// delegate narrows the validated shape, enforces the answer/abandon contract, and builds the typed
|
|
14
|
+
// completion variables the `feature-escalation` form + the `w_gw_answer` gateway expect.
|
|
15
|
+
|
|
16
|
+
import { completeEscalationAsHuman } from "../app/agentCompletion.ts";
|
|
17
|
+
import { featureRuns } from "../app/feature.ts";
|
|
18
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
19
|
+
|
|
20
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
21
|
+
|
|
22
|
+
export default defineOperation("answerFeatureEscalation", async ({ body }, app) => {
|
|
23
|
+
if (!body || typeof body !== "object") {
|
|
24
|
+
app.log.warn("answer-escalation rejected: missing request body");
|
|
25
|
+
return { status: 400, body: { ok: false, error: "userTaskKey and resolution are required" } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const userTaskKey = str(body.userTaskKey);
|
|
29
|
+
if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
|
|
30
|
+
|
|
31
|
+
const resolution = str(body.resolution);
|
|
32
|
+
if (resolution !== "answer" && resolution !== "abandon") {
|
|
33
|
+
return { status: 400, body: { ok: false, error: "resolution must be 'answer' or 'abandon'" } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The `feature-escalation` form completes with `{ resolution, answer }`; the `w_gw_answer` gateway
|
|
37
|
+
// re-dispatches implement-task with `answer` when resolution=answer, else routes to record-feature.
|
|
38
|
+
// Guidance is required for an answer (an empty answer re-dispatches the agent with nothing new).
|
|
39
|
+
const answer = str(body.answer);
|
|
40
|
+
if (resolution === "answer" && !answer) {
|
|
41
|
+
return { status: 400, body: { ok: false, error: "answer (guidance) is required when resolution is 'answer'" } };
|
|
42
|
+
}
|
|
43
|
+
const variables: Record<string, unknown> = resolution === "answer" ? { resolution, answer } : { resolution };
|
|
44
|
+
|
|
45
|
+
// The completing operator, for the attribution ledger. Optional — the UI has no per-operator auth,
|
|
46
|
+
// so default to a generic handle rather than blocking the answer.
|
|
47
|
+
const operatorId = str(body.operator) || "operator";
|
|
48
|
+
|
|
49
|
+
const r = await completeEscalationAsHuman(app.data, app.engine, { userTaskKey, operatorId, variables });
|
|
50
|
+
if (r.ok) {
|
|
51
|
+
// Reconcile this operation's OWN action immediately: clear the denormalised escalation pointer +
|
|
52
|
+
// question so the pages stop offering an answer affordance for a task that is now completed. Leave
|
|
53
|
+
// `status` to the poller (escalated → running on the answer loop) / record-feature (terminal on
|
|
54
|
+
// abandon), so we never overwrite a status the resumed run has already advanced to.
|
|
55
|
+
for (const run of await featureRuns(app.data).find({ escalation_user_task_key: userTaskKey })) {
|
|
56
|
+
await featureRuns(app.data).update(run.feature_key, {
|
|
57
|
+
escalation_question: null,
|
|
58
|
+
escalation_user_task_key: null,
|
|
59
|
+
updated_at: new Date().toISOString(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
app.log.info("operator answered feature escalation", { userTaskKey, resolution, elementId: r.elementId });
|
|
63
|
+
return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
|
|
64
|
+
}
|
|
65
|
+
const status = r.reason === "no open escalation task" ? 404 : 400;
|
|
66
|
+
app.log.warn("answer-escalation: not completed", { userTaskKey, reason: r.reason });
|
|
67
|
+
return { status, body: { ok: false, error: r.reason } };
|
|
68
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/feature.page.json
CHANGED
|
@@ -60,16 +60,17 @@
|
|
|
60
60
|
"source": "app",
|
|
61
61
|
"table": "feature_runs",
|
|
62
62
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
63
|
-
"filter": [{ "field": "status", "in": ["running", "awaiting_operator"] }]
|
|
63
|
+
"filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }]
|
|
64
64
|
},
|
|
65
65
|
"tabs": [
|
|
66
|
-
{ "label": "Active", "filter": [{ "field": "status", "in": ["running", "awaiting_operator"] }] },
|
|
66
|
+
{ "label": "Active", "filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }] },
|
|
67
67
|
{ "label": "History", "filter": [{ "field": "status", "in": ["opened", "converging", "merged", "converged", "blocked", "skipped", "failed", "abandoned"] }] },
|
|
68
68
|
{ "label": "All", "filter": [] }
|
|
69
69
|
],
|
|
70
70
|
"columns": [
|
|
71
71
|
{ "field": "feature_key", "header": "Feature", "linkField": "issue_url" },
|
|
72
72
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
73
|
+
{ "field": "escalation_question", "header": "Escalation" },
|
|
73
74
|
{ "field": "base_branch", "header": "Base branch" },
|
|
74
75
|
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
75
76
|
{ "field": "delivery_label", "header": "Delivery" },
|
|
@@ -77,7 +78,42 @@
|
|
|
77
78
|
{ "field": "auto_merge", "header": "Auto-merge" },
|
|
78
79
|
{ "field": "outcome", "header": "Outcome" },
|
|
79
80
|
{ "field": "updated_at", "header": "Updated" }
|
|
80
|
-
]
|
|
81
|
+
],
|
|
82
|
+
"rowActions": [
|
|
83
|
+
{
|
|
84
|
+
"label": "Abandon",
|
|
85
|
+
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
86
|
+
"showWhenField": "escalation_user_task_key",
|
|
87
|
+
"action": {
|
|
88
|
+
"path": "/app/api/actions/answer-escalation",
|
|
89
|
+
"body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"detail": {
|
|
94
|
+
"fields": [
|
|
95
|
+
{ "field": "escalation_question", "label": "Escalation question" },
|
|
96
|
+
{ "field": "outcome", "label": "Outcome" },
|
|
97
|
+
{ "field": "delivery_label", "label": "Delivery" }
|
|
98
|
+
],
|
|
99
|
+
"form": {
|
|
100
|
+
"showWhenField": "escalation_user_task_key",
|
|
101
|
+
"title": "Answer escalation",
|
|
102
|
+
"promptField": "escalation_question",
|
|
103
|
+
"inputKey": "answer",
|
|
104
|
+
"inputLabel": "Guidance for the implementation agent",
|
|
105
|
+
"submitLabel": "Answer & re-dispatch",
|
|
106
|
+
"action": {
|
|
107
|
+
"path": "/app/api/actions/answer-escalation",
|
|
108
|
+
"successLabel": "Answered — the agent will resume",
|
|
109
|
+
"body": {
|
|
110
|
+
"userTaskKey": "{{row.escalation_user_task_key}}",
|
|
111
|
+
"resolution": "answer",
|
|
112
|
+
"answer": "{{form.answer}}"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
81
117
|
}
|
|
82
118
|
}
|
|
83
119
|
]
|
package/pages/overview.page.json
CHANGED
|
@@ -119,18 +119,53 @@
|
|
|
119
119
|
"source": "app",
|
|
120
120
|
"table": "feature_runs",
|
|
121
121
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
122
|
-
"filter": [{ "field": "status", "in": ["running", "awaiting_operator"] }]
|
|
122
|
+
"filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }]
|
|
123
123
|
},
|
|
124
124
|
"columns": [
|
|
125
125
|
{ "field": "feature_key", "header": "Feature", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
|
|
126
126
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
127
|
+
{ "field": "escalation_question", "header": "Escalation" },
|
|
127
128
|
{ "field": "base_branch", "header": "Base branch" },
|
|
128
129
|
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
129
130
|
{ "field": "converge", "header": "Converge" },
|
|
130
131
|
{ "field": "auto_merge", "header": "Auto-merge" },
|
|
131
132
|
{ "field": "outcome", "header": "Outcome" },
|
|
132
133
|
{ "field": "updated_at", "header": "Updated" }
|
|
133
|
-
]
|
|
134
|
+
],
|
|
135
|
+
"rowActions": [
|
|
136
|
+
{
|
|
137
|
+
"label": "Abandon",
|
|
138
|
+
"confirm": "Abandon this escalated task? The run gives up on it (no PR).",
|
|
139
|
+
"showWhenField": "escalation_user_task_key",
|
|
140
|
+
"action": {
|
|
141
|
+
"path": "/app/api/actions/answer-escalation",
|
|
142
|
+
"body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
"detail": {
|
|
147
|
+
"fields": [
|
|
148
|
+
{ "field": "escalation_question", "label": "Escalation question" },
|
|
149
|
+
{ "field": "outcome", "label": "Outcome" }
|
|
150
|
+
],
|
|
151
|
+
"form": {
|
|
152
|
+
"showWhenField": "escalation_user_task_key",
|
|
153
|
+
"title": "Answer escalation",
|
|
154
|
+
"promptField": "escalation_question",
|
|
155
|
+
"inputKey": "answer",
|
|
156
|
+
"inputLabel": "Guidance for the implementation agent",
|
|
157
|
+
"submitLabel": "Answer & re-dispatch",
|
|
158
|
+
"action": {
|
|
159
|
+
"path": "/app/api/actions/answer-escalation",
|
|
160
|
+
"successLabel": "Answered — the agent will resume",
|
|
161
|
+
"body": {
|
|
162
|
+
"userTaskKey": "{{row.escalation_user_task_key}}",
|
|
163
|
+
"resolution": "answer",
|
|
164
|
+
"answer": "{{form.answer}}"
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
134
169
|
}
|
|
135
170
|
}
|
|
136
171
|
]
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
<nano:extend name="pr" type="string" optional="true" />
|
|
10
10
|
<nano:extend name="summary" type="string" optional="true" />
|
|
11
11
|
</nano:shape>
|
|
12
|
+
<nano:shape id="RecordFeatureEscalationIn" name="Record feature escalation — input">
|
|
13
|
+
<nano:extend name="featureKey" type="string" />
|
|
14
|
+
<nano:extend name="question" type="string" optional="true" />
|
|
15
|
+
</nano:shape>
|
|
12
16
|
<nano:shape id="ConvergeFeatureIn" name="Converge feature PR — input">
|
|
13
17
|
<nano:extend name="featureKey" type="string" />
|
|
14
18
|
<nano:extend name="prKey" type="string" optional="true" />
|
|
@@ -54,13 +58,23 @@
|
|
|
54
58
|
<bpmn:outgoing>w_escalate</bpmn:outgoing>
|
|
55
59
|
<bpmn:outgoing>w_done</bpmn:outgoing>
|
|
56
60
|
</bpmn:exclusiveGateway>
|
|
61
|
+
<bpmn:serviceTask id="record-feature-escalation" name="Record escalation">
|
|
62
|
+
<bpmn:extensionElements>
|
|
63
|
+
<zeebe:taskDefinition type="pr.record-feature-escalation" />
|
|
64
|
+
<zeebe:properties>
|
|
65
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordFeatureEscalationIn" />
|
|
66
|
+
</zeebe:properties>
|
|
67
|
+
</bpmn:extensionElements>
|
|
68
|
+
<bpmn:incoming>w_escalate</bpmn:incoming>
|
|
69
|
+
<bpmn:outgoing>w_toEscalationTask</bpmn:outgoing>
|
|
70
|
+
</bpmn:serviceTask>
|
|
57
71
|
<bpmn:userTask id="feature-escalation" name="Task escalation (human)">
|
|
58
72
|
<bpmn:extensionElements>
|
|
59
73
|
<zeebe:formDefinition formId="feature-escalation" />
|
|
60
74
|
<zeebe:userTask />
|
|
61
|
-
<zeebe:assignmentDefinition candidateGroups="operators" assignee="=escalationAssignee" />
|
|
75
|
+
<zeebe:assignmentDefinition candidateGroups="operators" assignee="=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != "") then escalationAssignee else null" />
|
|
62
76
|
</bpmn:extensionElements>
|
|
63
|
-
<bpmn:incoming>
|
|
77
|
+
<bpmn:incoming>w_toEscalationTask</bpmn:incoming>
|
|
64
78
|
<bpmn:outgoing>w_toAnswerGw</bpmn:outgoing>
|
|
65
79
|
</bpmn:userTask>
|
|
66
80
|
<bpmn:boundaryEvent id="be_feature_sla" name="SLA elapsed" attachedToRef="feature-escalation">
|
|
@@ -134,9 +148,10 @@
|
|
|
134
148
|
<bpmn:sequenceFlow id="f_toEnsureBase" sourceRef="Start" targetRef="ensure-base-branch" />
|
|
135
149
|
<bpmn:sequenceFlow id="f_toImplement" sourceRef="ensure-base-branch" targetRef="implement-task" />
|
|
136
150
|
<bpmn:sequenceFlow id="w_toGw" sourceRef="implement-task" targetRef="w_gw" />
|
|
137
|
-
<bpmn:sequenceFlow id="w_escalate" name="escalated" sourceRef="w_gw" targetRef="feature-escalation">
|
|
151
|
+
<bpmn:sequenceFlow id="w_escalate" name="escalated" sourceRef="w_gw" targetRef="record-feature-escalation">
|
|
138
152
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "escalated" and question != null and trim(question) != ""</bpmn:conditionExpression>
|
|
139
153
|
</bpmn:sequenceFlow>
|
|
154
|
+
<bpmn:sequenceFlow id="w_toEscalationTask" sourceRef="record-feature-escalation" targetRef="feature-escalation" />
|
|
140
155
|
<bpmn:sequenceFlow id="w_done" name="done" sourceRef="w_gw" targetRef="record-feature" />
|
|
141
156
|
<bpmn:sequenceFlow id="w_toAnswerGw" sourceRef="feature-escalation" targetRef="w_gw_answer" />
|
|
142
157
|
<bpmn:sequenceFlow id="w_answerLoop" name="answer" sourceRef="w_gw_answer" targetRef="implement-task">
|
|
@@ -177,49 +192,52 @@
|
|
|
177
192
|
<dc:Bounds x="604" y="76" width="74" height="14" />
|
|
178
193
|
</bpmndi:BPMNLabel>
|
|
179
194
|
</bpmndi:BPMNShape>
|
|
180
|
-
<bpmndi:BPMNShape id="
|
|
195
|
+
<bpmndi:BPMNShape id="BPMNShape_record-feature-escalation" bpmnElement="record-feature-escalation">
|
|
181
196
|
<dc:Bounds x="766" y="240" width="100" height="80" />
|
|
182
197
|
</bpmndi:BPMNShape>
|
|
198
|
+
<bpmndi:BPMNShape id="BPMNShape_feature-escalation" bpmnElement="feature-escalation">
|
|
199
|
+
<dc:Bounds x="966" y="240" width="100" height="80" />
|
|
200
|
+
</bpmndi:BPMNShape>
|
|
183
201
|
<bpmndi:BPMNShape id="BPMNShape_w_gw_answer" bpmnElement="w_gw_answer" isMarkerVisible="true">
|
|
184
|
-
<dc:Bounds x="
|
|
202
|
+
<dc:Bounds x="1166" y="255" width="50" height="50" />
|
|
185
203
|
<bpmndi:BPMNLabel>
|
|
186
|
-
<dc:Bounds x="
|
|
204
|
+
<dc:Bounds x="1163" y="236" width="56" height="14" />
|
|
187
205
|
</bpmndi:BPMNLabel>
|
|
188
206
|
</bpmndi:BPMNShape>
|
|
189
207
|
<bpmndi:BPMNShape id="BPMNShape_record-feature" bpmnElement="record-feature">
|
|
190
|
-
<dc:Bounds x="
|
|
208
|
+
<dc:Bounds x="1316" y="80" width="100" height="80" />
|
|
191
209
|
</bpmndi:BPMNShape>
|
|
192
210
|
<bpmndi:BPMNShape id="BPMNShape_gw-blocked" bpmnElement="gw-blocked" isMarkerVisible="true">
|
|
193
|
-
<dc:Bounds x="
|
|
211
|
+
<dc:Bounds x="1516" y="95" width="50" height="50" />
|
|
194
212
|
<bpmndi:BPMNLabel>
|
|
195
|
-
<dc:Bounds x="
|
|
213
|
+
<dc:Bounds x="1511" y="76" width="60" height="14" />
|
|
196
214
|
</bpmndi:BPMNLabel>
|
|
197
215
|
</bpmndi:BPMNShape>
|
|
198
216
|
<bpmndi:BPMNShape id="BPMNShape_feature-blocked" bpmnElement="feature-blocked">
|
|
199
|
-
<dc:Bounds x="
|
|
217
|
+
<dc:Bounds x="1666" y="400" width="100" height="80" />
|
|
200
218
|
</bpmndi:BPMNShape>
|
|
201
219
|
<bpmndi:BPMNShape id="BPMNShape_record-blocked-ack" bpmnElement="record-blocked-ack">
|
|
202
|
-
<dc:Bounds x="
|
|
220
|
+
<dc:Bounds x="1866" y="400" width="100" height="80" />
|
|
203
221
|
</bpmndi:BPMNShape>
|
|
204
222
|
<bpmndi:BPMNShape id="BPMNShape_gw-converge" bpmnElement="gw-converge" isMarkerVisible="true">
|
|
205
|
-
<dc:Bounds x="
|
|
223
|
+
<dc:Bounds x="1691" y="95" width="50" height="50" />
|
|
206
224
|
<bpmndi:BPMNLabel>
|
|
207
|
-
<dc:Bounds x="
|
|
225
|
+
<dc:Bounds x="1683" y="76" width="67" height="14" />
|
|
208
226
|
</bpmndi:BPMNLabel>
|
|
209
227
|
</bpmndi:BPMNShape>
|
|
210
228
|
<bpmndi:BPMNShape id="BPMNShape_converge" bpmnElement="converge">
|
|
211
|
-
<dc:Bounds x="
|
|
229
|
+
<dc:Bounds x="1866" y="240" width="100" height="80" />
|
|
212
230
|
</bpmndi:BPMNShape>
|
|
213
231
|
<bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
|
|
214
|
-
<dc:Bounds x="
|
|
232
|
+
<dc:Bounds x="2066" y="102" width="36" height="36" />
|
|
215
233
|
<bpmndi:BPMNLabel>
|
|
216
|
-
<dc:Bounds x="
|
|
234
|
+
<dc:Bounds x="2067" y="83" width="34" height="14" />
|
|
217
235
|
</bpmndi:BPMNLabel>
|
|
218
236
|
</bpmndi:BPMNShape>
|
|
219
237
|
<bpmndi:BPMNShape id="BPMNShape_be_feature_sla" bpmnElement="be_feature_sla">
|
|
220
|
-
<dc:Bounds x="
|
|
238
|
+
<dc:Bounds x="998" y="302" width="36" height="36" />
|
|
221
239
|
<bpmndi:BPMNLabel>
|
|
222
|
-
<dc:Bounds x="
|
|
240
|
+
<dc:Bounds x="974" y="383" width="84" height="14" />
|
|
223
241
|
</bpmndi:BPMNLabel>
|
|
224
242
|
</bpmndi:BPMNShape>
|
|
225
243
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toEnsureBase" bpmnElement="f_toEnsureBase">
|
|
@@ -236,35 +254,35 @@
|
|
|
236
254
|
</bpmndi:BPMNEdge>
|
|
237
255
|
<bpmndi:BPMNEdge id="BPMNEdge_w_done" bpmnElement="w_done">
|
|
238
256
|
<di:waypoint x="666" y="120" />
|
|
239
|
-
<di:waypoint x="
|
|
257
|
+
<di:waypoint x="1316" y="120" />
|
|
240
258
|
<bpmndi:BPMNLabel>
|
|
241
|
-
<dc:Bounds x="
|
|
259
|
+
<dc:Bounds x="975" y="98" width="32" height="14" />
|
|
242
260
|
</bpmndi:BPMNLabel>
|
|
243
261
|
</bpmndi:BPMNEdge>
|
|
244
262
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toBlockedGw" bpmnElement="f_toBlockedGw">
|
|
245
|
-
<di:waypoint x="
|
|
246
|
-
<di:waypoint x="
|
|
263
|
+
<di:waypoint x="1416" y="120" />
|
|
264
|
+
<di:waypoint x="1516" y="120" />
|
|
247
265
|
</bpmndi:BPMNEdge>
|
|
248
266
|
<bpmndi:BPMNEdge id="BPMNEdge_f_notBlocked" bpmnElement="f_notBlocked">
|
|
249
|
-
<di:waypoint x="
|
|
250
|
-
<di:waypoint x="
|
|
267
|
+
<di:waypoint x="1566" y="120" />
|
|
268
|
+
<di:waypoint x="1691" y="120" />
|
|
251
269
|
<bpmndi:BPMNLabel>
|
|
252
|
-
<dc:Bounds x="
|
|
270
|
+
<dc:Bounds x="1590" y="98" width="78" height="14" />
|
|
253
271
|
</bpmndi:BPMNLabel>
|
|
254
272
|
</bpmndi:BPMNEdge>
|
|
255
273
|
<bpmndi:BPMNEdge id="BPMNEdge_f_noConverge" bpmnElement="f_noConverge">
|
|
256
|
-
<di:waypoint x="
|
|
257
|
-
<di:waypoint x="
|
|
274
|
+
<di:waypoint x="1741" y="120" />
|
|
275
|
+
<di:waypoint x="2066" y="120" />
|
|
258
276
|
<bpmndi:BPMNLabel>
|
|
259
|
-
<dc:Bounds x="
|
|
277
|
+
<dc:Bounds x="1868" y="98" width="71" height="14" />
|
|
260
278
|
</bpmndi:BPMNLabel>
|
|
261
279
|
</bpmndi:BPMNEdge>
|
|
262
280
|
<bpmndi:BPMNEdge id="BPMNEdge_w_abandon" bpmnElement="w_abandon">
|
|
263
|
-
<di:waypoint x="
|
|
264
|
-
<di:waypoint x="
|
|
265
|
-
<di:waypoint x="
|
|
281
|
+
<di:waypoint x="1216" y="280" />
|
|
282
|
+
<di:waypoint x="1366" y="280" />
|
|
283
|
+
<di:waypoint x="1366" y="160" />
|
|
266
284
|
<bpmndi:BPMNLabel>
|
|
267
|
-
<dc:Bounds x="
|
|
285
|
+
<dc:Bounds x="1265" y="258" width="53" height="14" />
|
|
268
286
|
</bpmndi:BPMNLabel>
|
|
269
287
|
</bpmndi:BPMNEdge>
|
|
270
288
|
<bpmndi:BPMNEdge id="BPMNEdge_w_escalate" bpmnElement="w_escalate">
|
|
@@ -276,55 +294,59 @@
|
|
|
276
294
|
</bpmndi:BPMNLabel>
|
|
277
295
|
</bpmndi:BPMNEdge>
|
|
278
296
|
<bpmndi:BPMNEdge id="BPMNEdge_f_blocked" bpmnElement="f_blocked">
|
|
279
|
-
<di:waypoint x="
|
|
280
|
-
<di:waypoint x="
|
|
281
|
-
<di:waypoint x="
|
|
297
|
+
<di:waypoint x="1541" y="145" />
|
|
298
|
+
<di:waypoint x="1541" y="440" />
|
|
299
|
+
<di:waypoint x="1666" y="440" />
|
|
282
300
|
<bpmndi:BPMNLabel>
|
|
283
|
-
<dc:Bounds x="
|
|
301
|
+
<dc:Bounds x="1546" y="286" width="53" height="14" />
|
|
284
302
|
</bpmndi:BPMNLabel>
|
|
285
303
|
</bpmndi:BPMNEdge>
|
|
286
304
|
<bpmndi:BPMNEdge id="BPMNEdge_f_converge" bpmnElement="f_converge">
|
|
287
|
-
<di:waypoint x="
|
|
288
|
-
<di:waypoint x="
|
|
289
|
-
<di:waypoint x="
|
|
305
|
+
<di:waypoint x="1716" y="145" />
|
|
306
|
+
<di:waypoint x="1716" y="280" />
|
|
307
|
+
<di:waypoint x="1866" y="280" />
|
|
290
308
|
<bpmndi:BPMNLabel>
|
|
291
|
-
<dc:Bounds x="
|
|
309
|
+
<dc:Bounds x="1721" y="206" width="60" height="14" />
|
|
292
310
|
</bpmndi:BPMNLabel>
|
|
293
311
|
</bpmndi:BPMNEdge>
|
|
294
|
-
<bpmndi:BPMNEdge id="
|
|
312
|
+
<bpmndi:BPMNEdge id="BPMNEdge_w_toEscalationTask" bpmnElement="w_toEscalationTask">
|
|
295
313
|
<di:waypoint x="866" y="280" />
|
|
296
314
|
<di:waypoint x="966" y="280" />
|
|
297
315
|
</bpmndi:BPMNEdge>
|
|
316
|
+
<bpmndi:BPMNEdge id="BPMNEdge_w_toAnswerGw" bpmnElement="w_toAnswerGw">
|
|
317
|
+
<di:waypoint x="1066" y="280" />
|
|
318
|
+
<di:waypoint x="1166" y="280" />
|
|
319
|
+
</bpmndi:BPMNEdge>
|
|
298
320
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toBlockedAck" bpmnElement="f_toBlockedAck">
|
|
299
|
-
<di:waypoint x="
|
|
300
|
-
<di:waypoint x="
|
|
321
|
+
<di:waypoint x="1766" y="440" />
|
|
322
|
+
<di:waypoint x="1866" y="440" />
|
|
301
323
|
</bpmndi:BPMNEdge>
|
|
302
324
|
<bpmndi:BPMNEdge id="BPMNEdge_f_blockedEnd" bpmnElement="f_blockedEnd">
|
|
303
|
-
<di:waypoint x="
|
|
304
|
-
<di:waypoint x="
|
|
305
|
-
<di:waypoint x="
|
|
325
|
+
<di:waypoint x="1966" y="440" />
|
|
326
|
+
<di:waypoint x="2084" y="440" />
|
|
327
|
+
<di:waypoint x="2084" y="138" />
|
|
306
328
|
</bpmndi:BPMNEdge>
|
|
307
329
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toEndConverged" bpmnElement="f_toEndConverged">
|
|
308
|
-
<di:waypoint x="
|
|
309
|
-
<di:waypoint x="
|
|
310
|
-
<di:waypoint x="
|
|
330
|
+
<di:waypoint x="1966" y="280" />
|
|
331
|
+
<di:waypoint x="2084" y="280" />
|
|
332
|
+
<di:waypoint x="2084" y="138" />
|
|
311
333
|
</bpmndi:BPMNEdge>
|
|
312
334
|
<bpmndi:BPMNEdge id="BPMNEdge_w_sla" bpmnElement="w_sla">
|
|
313
|
-
<di:waypoint x="
|
|
314
|
-
<di:waypoint x="
|
|
315
|
-
<di:waypoint x="
|
|
316
|
-
<di:waypoint x="
|
|
335
|
+
<di:waypoint x="1016" y="338" />
|
|
336
|
+
<di:waypoint x="1016" y="358" />
|
|
337
|
+
<di:waypoint x="1366" y="358" />
|
|
338
|
+
<di:waypoint x="1366" y="160" />
|
|
317
339
|
<bpmndi:BPMNLabel>
|
|
318
|
-
<dc:Bounds x="
|
|
340
|
+
<dc:Bounds x="1207" y="325" width="88" height="28" />
|
|
319
341
|
</bpmndi:BPMNLabel>
|
|
320
342
|
</bpmndi:BPMNEdge>
|
|
321
343
|
<bpmndi:BPMNEdge id="BPMNEdge_w_answerLoop" bpmnElement="w_answerLoop">
|
|
322
|
-
<di:waypoint x="
|
|
323
|
-
<di:waypoint x="
|
|
344
|
+
<di:waypoint x="1191" y="305" />
|
|
345
|
+
<di:waypoint x="1191" y="360" />
|
|
324
346
|
<di:waypoint x="466" y="360" />
|
|
325
347
|
<di:waypoint x="466" y="160" />
|
|
326
348
|
<bpmndi:BPMNLabel>
|
|
327
|
-
<dc:Bounds x="
|
|
349
|
+
<dc:Bounds x="804" y="338" width="49" height="14" />
|
|
328
350
|
</bpmndi:BPMNLabel>
|
|
329
351
|
</bpmndi:BPMNEdge>
|
|
330
352
|
</bpmndi:BPMNPlane>
|
|
@@ -267,7 +267,7 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
267
267
|
"merging",
|
|
268
268
|
],
|
|
269
269
|
plans: ["planning", "dispatched"],
|
|
270
|
-
feature_runs: ["running", "awaiting_operator"],
|
|
270
|
+
feature_runs: ["running", "escalated", "awaiting_operator"],
|
|
271
271
|
};
|
|
272
272
|
const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
|
|
273
273
|
for (const [table, statuses] of Object.entries(expected)) {
|
|
@@ -54,6 +54,12 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
54
54
|
status: rowStatus,
|
|
55
55
|
pr_key: prKey,
|
|
56
56
|
outcome: summary ?? null,
|
|
57
|
+
// The run has left the `feature-escalation` wait (answered → abandon, SLA auto-abandon, or the
|
|
58
|
+
// agent finished without escalating), so clear the denormalised escalation pointer the pages gate
|
|
59
|
+
// on. pollFeatureEscalations only sweeps `running`/`escalated` runs, so a terminal-ward transition
|
|
60
|
+
// through here must clear it itself or the stale question would linger on the row.
|
|
61
|
+
escalation_question: null,
|
|
62
|
+
escalation_user_task_key: null,
|
|
57
63
|
updated_at: ts,
|
|
58
64
|
});
|
|
59
65
|
app.log.info("record-feature", { featureKey, featureStatus, rowStatus, prKey });
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Unit coverage for pr.record-feature-escalation — a feature run parked on the native
|
|
2
|
+
// `feature-escalation` user task (issue #210). Runs on the `escalated` arm, before the user task
|
|
3
|
+
// exists, and must flip the row to the non-terminal `escalated` status and persist the agent's
|
|
4
|
+
// `question` so the nwf UI can surface it (the poller can't read task-local vars, so this is the
|
|
5
|
+
// question's source of truth). It clears the completable-task pointer to NULL (the task does not
|
|
6
|
+
// exist yet, so any non-null value is stale); the poller fills the real key once it is observable.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assertEquals } from "#test-assert";
|
|
9
|
+
import { noopLog } from "../../test/log.ts";
|
|
10
|
+
import handler from "./worker.ts";
|
|
11
|
+
|
|
12
|
+
// biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-blocked-ack.worker.test
|
|
13
|
+
function fakeApp(rows: Record<string, unknown>[]): any {
|
|
14
|
+
const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
|
|
15
|
+
return {
|
|
16
|
+
data: {
|
|
17
|
+
table(name: string, key: string) {
|
|
18
|
+
const store = (stores[name] ??= []);
|
|
19
|
+
return {
|
|
20
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
21
|
+
get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
23
|
+
find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
25
|
+
insert: (row: any) => {
|
|
26
|
+
store.push(row);
|
|
27
|
+
return Promise.resolve(store.length);
|
|
28
|
+
},
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
30
|
+
update: (k: any, patch: any) => {
|
|
31
|
+
const row = store.find((r) => r[key] === k);
|
|
32
|
+
if (row) Object.assign(row, patch);
|
|
33
|
+
return Promise.resolve(row);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
log: noopLog(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("record-feature-escalation: flips the run to escalated and persists the question", async () => {
|
|
43
|
+
const rows = [{ feature_key: "owner/repo#7", status: "running", escalation_question: null, escalation_user_task_key: null }];
|
|
44
|
+
const app = fakeApp(rows);
|
|
45
|
+
const out = await handler(
|
|
46
|
+
{ variables: { featureKey: "owner/repo#7", question: "Which API should I use?" } } as never,
|
|
47
|
+
app,
|
|
48
|
+
);
|
|
49
|
+
assertEquals(out, {});
|
|
50
|
+
assertEquals(rows[0].status, "escalated");
|
|
51
|
+
assertEquals(rows[0].escalation_question, "Which API should I use?");
|
|
52
|
+
// The user task does not exist yet — the pointer is cleared to NULL here (poller fills the real key).
|
|
53
|
+
assertEquals(rows[0].escalation_user_task_key, null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("record-feature-escalation: clears a stale completable-task pointer at escalation entry", async () => {
|
|
57
|
+
// The task does not exist yet here, so a non-null key can only be stale (a prior escalation's key
|
|
58
|
+
// left behind, a manual DB repair, etc). Leaving it would bind the pages' answer/abandon affordance
|
|
59
|
+
// to the wrong task until the poller overwrites it; clear it so the row reads "key unknown here".
|
|
60
|
+
const rows = [{ feature_key: "owner/repo#9", status: "running", escalation_question: null, escalation_user_task_key: "stale-ut" }];
|
|
61
|
+
const app = fakeApp(rows);
|
|
62
|
+
await handler({ variables: { featureKey: "owner/repo#9", question: "Q?" } } as never, app);
|
|
63
|
+
assertEquals(rows[0].status, "escalated");
|
|
64
|
+
assertEquals(rows[0].escalation_question, "Q?");
|
|
65
|
+
assertEquals(rows[0].escalation_user_task_key, null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("record-feature-escalation: a blank/absent question is persisted as NULL (badge/affordance stay off)", async () => {
|
|
69
|
+
const rows = [{ feature_key: "owner/repo#8", status: "running", escalation_question: null }];
|
|
70
|
+
const app = fakeApp(rows);
|
|
71
|
+
await handler({ variables: { featureKey: "owner/repo#8", question: " " } } as never, app);
|
|
72
|
+
assertEquals(rows[0].status, "escalated");
|
|
73
|
+
assertEquals(rows[0].escalation_question, null);
|
|
74
|
+
});
|