@nanobpm/nano-workforce 0.168.2 → 0.170.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/openapi.yaml CHANGED
@@ -108,8 +108,31 @@ components:
108
108
  type: string
109
109
  nullable: true
110
110
  openEscalation:
111
- type: string
111
+ type: object
112
112
  nullable: true
113
+ additionalProperties: false
114
+ description: >-
115
+ The structured pointer to this PR's OPEN escalation when it is parked awaiting a human/agent
116
+ answer (issue #666), else null. Derived from the same `user_tasks` read model as
117
+ `listEscalations`, so `userTaskKey` is the completable key an agent answers via
118
+ `completeUserTask` / `agentCompleteEscalation` (no `/tasks/api/tasks` curl needed). `kind`
119
+ is the BPMN escalation elementId (e.g. `wait-answer`, `wait-merge-answer`); `summary` is the
120
+ raised question/findings (null when none was recorded).
121
+ required:
122
+ - userTaskKey
123
+ - kind
124
+ - summary
125
+ properties:
126
+ userTaskKey:
127
+ type: string
128
+ description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
129
+ kind:
130
+ type: string
131
+ description: The BPMN escalation elementId (the escalation kind).
132
+ summary:
133
+ type: string
134
+ nullable: true
135
+ description: The raised question / findings, denormalised for display; null when none.
113
136
  updatedAt:
114
137
  type: string
115
138
  activeWorker:
@@ -130,6 +153,85 @@ components:
130
153
  type: array
131
154
  items:
132
155
  $ref: "#/components/schemas/ActivePr"
156
+ Escalation:
157
+ type: object
158
+ description: >-
159
+ One OPEN native user-task escalation awaiting a human/agent decision (issue #666), projected
160
+ from the `user_tasks` read model. `userTaskKey` is the completable key an agent answers via
161
+ `completeUserTask` / `agentCompleteEscalation`; `kind` is the BPMN escalation elementId.
162
+ additionalProperties: false
163
+ required:
164
+ - userTaskKey
165
+ - kind
166
+ - kindLabel
167
+ - prKey
168
+ - subjectType
169
+ - subjectKey
170
+ - subjectTitle
171
+ - subjectUrl
172
+ - question
173
+ - formKey
174
+ - processKey
175
+ - formVariables
176
+ properties:
177
+ userTaskKey:
178
+ type: string
179
+ description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
180
+ kind:
181
+ type: string
182
+ description: The BPMN escalation elementId (e.g. wait-answer, wait-merge-answer, plan-review-decision, trial-merge-decision, feature-escalation).
183
+ kindLabel:
184
+ type: string
185
+ description: Human-readable kind label (e.g. "PR review", "Plan review", "Trial merge").
186
+ prKey:
187
+ type: string
188
+ nullable: true
189
+ description: The PR key when this escalation belongs to a PR (review/merge loop); null for feature / plan / delivery / agent subjects.
190
+ subjectType:
191
+ type: string
192
+ description: The domain subject kind — feature | plan | pr | delivery | agent.
193
+ subjectKey:
194
+ type: string
195
+ description: The subject aggregate key (feature_key / plan_key / pr_key).
196
+ subjectTitle:
197
+ type: string
198
+ description: The subject's human-readable title (coalesced to subjectKey when unknown).
199
+ subjectUrl:
200
+ type: string
201
+ nullable: true
202
+ description: An optional external link (the issue/PR URL); null when none.
203
+ question:
204
+ type: string
205
+ nullable: true
206
+ description: The raised question / findings / task text the loop or agent recorded; null when none.
207
+ formKey:
208
+ type: string
209
+ nullable: true
210
+ description: The deployed `.form` key of the parked user task, for rendering/answering; null when unresolved.
211
+ processKey:
212
+ type: string
213
+ nullable: true
214
+ description: The owning engine process-instance key; null when unknown.
215
+ formVariables:
216
+ type: object
217
+ additionalProperties: true
218
+ description: >-
219
+ The denormalised decision/form context the Tasks inbox renders for this task (the same
220
+ question + subject the deployed form is seeded with). The typed answer fields an agent
221
+ submits depend on `kind` (e.g. a PR `{ answer }`, a plan-review `{ directive, notes }`).
222
+ EscalationList:
223
+ type: object
224
+ additionalProperties: false
225
+ required:
226
+ - count
227
+ - escalations
228
+ properties:
229
+ count:
230
+ type: integer
231
+ escalations:
232
+ type: array
233
+ items:
234
+ $ref: "#/components/schemas/Escalation"
133
235
  LineagePrView:
134
236
  type: object
135
237
  description: A member PR of a lineage thread (issue #245).
@@ -223,6 +325,93 @@ components:
223
325
  type: array
224
326
  items:
225
327
  $ref: "#/components/schemas/LineageThreadView"
328
+ PrHistoryRound:
329
+ type: object
330
+ description: One convergence round in a PR's timeline (issue #668) — its status transition/outcome, owning worker, and timestamps. Sourced from the `rounds` table the Convergence page reads.
331
+ additionalProperties: false
332
+ required:
333
+ - roundNo
334
+ - status
335
+ - worker
336
+ - summary
337
+ - startedAt
338
+ - endedAt
339
+ properties:
340
+ roundNo:
341
+ type: integer
342
+ status:
343
+ type: string
344
+ nullable: true
345
+ description: The round's result/status transition (converged|addressed|waiting|needs_input|blocked).
346
+ worker:
347
+ type: string
348
+ nullable: true
349
+ description: The worker that ran this round.
350
+ summary:
351
+ type: string
352
+ nullable: true
353
+ description: The round outcome summary.
354
+ startedAt:
355
+ type: string
356
+ endedAt:
357
+ type: string
358
+ nullable: true
359
+ PrHistoryEscalation:
360
+ type: object
361
+ description: One escalation in a PR's history (issue #668) — its kind, question/answer, status, and timestamps. Sourced from the `escalations` table the Convergence page reads.
362
+ additionalProperties: false
363
+ required:
364
+ - roundNo
365
+ - kind
366
+ - worker
367
+ - question
368
+ - answer
369
+ - status
370
+ - askedAt
371
+ - answeredAt
372
+ properties:
373
+ roundNo:
374
+ type: integer
375
+ kind:
376
+ type: string
377
+ description: The escalation kind (question|blocker).
378
+ worker:
379
+ type: string
380
+ nullable: true
381
+ description: The worker that raised this escalation.
382
+ question:
383
+ type: string
384
+ answer:
385
+ type: string
386
+ nullable: true
387
+ description: The human's answer; null while the escalation is still open.
388
+ status:
389
+ type: string
390
+ description: open | answered | stale.
391
+ askedAt:
392
+ type: string
393
+ answeredAt:
394
+ type: string
395
+ nullable: true
396
+ PrHistory:
397
+ type: object
398
+ description: A PR's full escalation + round history (issue #668), as surfaced read-only on the Convergence page's PR detail. Empty arrays when the PR has no history (or is unknown).
399
+ additionalProperties: false
400
+ required:
401
+ - prKey
402
+ - rounds
403
+ - escalations
404
+ properties:
405
+ prKey:
406
+ type: string
407
+ rounds:
408
+ type: array
409
+ items:
410
+ $ref: "#/components/schemas/PrHistoryRound"
411
+ escalations:
412
+ type: array
413
+ items:
414
+ $ref: "#/components/schemas/PrHistoryEscalation"
226
415
  AgenticSupplyWorker:
227
416
  type: object
228
417
  description: One connected worker in the supply mirror (H5 cockpit; sourced from the H1 presence registry).
@@ -2970,6 +3159,36 @@ paths:
2970
3159
  application/json:
2971
3160
  schema:
2972
3161
  $ref: "#/components/schemas/ErrorBody"
3162
+ /escalations:
3163
+ get:
3164
+ operationId: listEscalations
3165
+ summary: List every OPEN escalation awaiting a human/agent decision, with the completable userTaskKey.
3166
+ description: >-
3167
+ Discovery for the escalation-answer path (epic #664, issue #666): across every surfaced
3168
+ escalation kind (PR review/merge loop, plan-review, empty-plan, trial-merge,
3169
+ conformance-review, delivery human-step, feature/blocked, agent-permission and the shared
3170
+ human-escalation cell), list each currently-open native user-task escalation with the
3171
+ completable `userTaskKey` an agent then answers via `completeUserTask` /
3172
+ `agentCompleteEscalation` — so a tool-aware agent never has to curl the un-projected
3173
+ `/tasks/api/tasks` inbox to find keys. Read-only projection over the ONE `user_tasks` read
3174
+ model the Tasks inbox and Convergence page consume (no second source of truth); a row is
3175
+ present iff its task is open, so the list reflects live pending work.
3176
+ security:
3177
+ - hookSecret: []
3178
+ - {}
3179
+ responses:
3180
+ "200":
3181
+ description: The open escalations.
3182
+ content:
3183
+ application/json:
3184
+ schema:
3185
+ $ref: "#/components/schemas/EscalationList"
3186
+ "401":
3187
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3188
+ content:
3189
+ application/json:
3190
+ schema:
3191
+ $ref: "#/components/schemas/ErrorBody"
2973
3192
  /lineage:
2974
3193
  get:
2975
3194
  operationId: getLineage
@@ -2997,6 +3216,45 @@ paths:
2997
3216
  application/json:
2998
3217
  schema:
2999
3218
  $ref: "#/components/schemas/ErrorBody"
3219
+ /prs/history:
3220
+ get:
3221
+ operationId: getPrHistory
3222
+ summary: "A PR's escalation + round history (issue #668): per-round status transitions and outcome, plus each escalation's kind, question/answer, and timestamps — the read that retires the DB-over-ssh fallback. Identify the PR by prKey, or by processKey. Reads the same rounds/escalations tables the Convergence page surfaces."
3223
+ security:
3224
+ - hookSecret: []
3225
+ - {}
3226
+ parameters:
3227
+ - name: prKey
3228
+ in: query
3229
+ required: false
3230
+ schema:
3231
+ type: string
3232
+ description: The PR key ("<owner>/<repo>#<number>"). Provide this or processKey.
3233
+ - name: processKey
3234
+ in: query
3235
+ required: false
3236
+ schema:
3237
+ type: string
3238
+ description: The engine process-instance key driving the PR; resolved to its prKey. Provide this or prKey.
3239
+ responses:
3240
+ "200":
3241
+ description: The PR's round + escalation history (empty arrays when there is none).
3242
+ content:
3243
+ application/json:
3244
+ schema:
3245
+ $ref: "#/components/schemas/PrHistory"
3246
+ "400":
3247
+ description: Neither prKey nor processKey supplied.
3248
+ content:
3249
+ application/json:
3250
+ schema:
3251
+ $ref: "#/components/schemas/ErrorBody"
3252
+ "401":
3253
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3254
+ content:
3255
+ application/json:
3256
+ schema:
3257
+ $ref: "#/components/schemas/ErrorBody"
3000
3258
  /agentic/supply:
3001
3259
  get:
3002
3260
  operationId: getAgenticSupply
@@ -0,0 +1,137 @@
1
+ // Tests for GET /app/api/prs/history → operation `getPrHistory` (issue #668, N4 of epic #664).
2
+ // The headline scenario: a PR that escalated and then RESUMED (its escalation answered, a fresh round
3
+ // recorded) exposes its full history — every round's status/outcome plus the answered escalation's
4
+ // question/answer — through the tool, with NO DB access (the handler reads the same `rounds`/
5
+ // `escalations` tables the Convergence page surfaces, via a minimal in-memory DataLayer). Also covers
6
+ // the processKey → prKey resolution, the prKey/processKey-required 400, and the shared-secret guard.
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { noopLog } from "../test/log.ts";
11
+ import handler from "./getPrHistory.ts";
12
+
13
+ // biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row shapes.
14
+ type Row = any;
15
+
16
+ function memApp(tables: { rounds?: Row[]; escalations?: Row[]; pull_requests?: Row[] }): AppApi {
17
+ const stores: Record<string, Row[]> = {
18
+ rounds: tables.rounds ?? [],
19
+ escalations: tables.escalations ?? [],
20
+ pull_requests: tables.pull_requests ?? [],
21
+ };
22
+ const table = (name: string) => ({
23
+ async find(where: Record<string, unknown>) {
24
+ return (stores[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
25
+ },
26
+ });
27
+ return { data: { table }, log: noopLog() } as unknown as AppApi;
28
+ }
29
+
30
+ function input(query: Record<string, string>, headers: Record<string, string> = {}) {
31
+ return {
32
+ req: {
33
+ method: "GET",
34
+ path: "/app/api/prs/history",
35
+ query: new URLSearchParams(query),
36
+ headers: new Headers(headers),
37
+ text: async () => "",
38
+ } as unknown,
39
+ params: {},
40
+ query,
41
+ body: undefined,
42
+ };
43
+ }
44
+
45
+ // A PR that escalated in round 1 (blocker), had that escalation answered, and RESUMED into round 2.
46
+ function escalatedThenResumed() {
47
+ return memApp({
48
+ pull_requests: [{ pr_key: "o/r#7", process_key: "proc-7" }],
49
+ rounds: [
50
+ { id: 10, pr_key: "o/r#7", round_no: 1, status: "needs_input", summary: "hit an ambiguity", worker: "senior-a", started_at: "2026-03-01T00:00:00Z", ended_at: "2026-03-01T00:05:00Z" },
51
+ { id: 11, pr_key: "o/r#7", round_no: 2, status: "converged", summary: "resolved after the answer", worker: "senior-a", started_at: "2026-03-01T01:00:00Z", ended_at: "2026-03-01T01:05:00Z" },
52
+ ],
53
+ escalations: [
54
+ { id: 20, pr_key: "o/r#7", round_no: 1, kind: "blocker", question: "Which base branch?", answer: "main", status: "answered", worker: "senior-a", asked_at: "2026-03-01T00:03:00Z", answered_at: "2026-03-01T00:50:00Z" },
55
+ ],
56
+ });
57
+ }
58
+
59
+ test("an escalated-then-resumed PR exposes its round + escalation history by prKey", async () => {
60
+ const res = (await handler(input({ prKey: "o/r#7" }), escalatedThenResumed())) as Row;
61
+ assertEquals(res.status, 200);
62
+ assertEquals(res.body.prKey, "o/r#7");
63
+
64
+ // Both rounds surface, in round order, with their status transition + outcome summary.
65
+ assertEquals(res.body.rounds.length, 2);
66
+ assertEquals(res.body.rounds[0].roundNo, 1);
67
+ assertEquals(res.body.rounds[0].status, "needs_input");
68
+ assertEquals(res.body.rounds[1].roundNo, 2);
69
+ assertEquals(res.body.rounds[1].status, "converged");
70
+ assertEquals(res.body.rounds[1].summary, "resolved after the answer");
71
+
72
+ // The escalation surfaces with its kind, question, answer, and timestamps — the "why did it
73
+ // escalate / what was answered" the tool exists to expose.
74
+ assertEquals(res.body.escalations.length, 1);
75
+ const e = res.body.escalations[0];
76
+ assertEquals(e.kind, "blocker");
77
+ assertEquals(e.question, "Which base branch?");
78
+ assertEquals(e.answer, "main");
79
+ assertEquals(e.status, "answered");
80
+ assertEquals(e.roundNo, 1);
81
+ assertEquals(e.askedAt, "2026-03-01T00:03:00Z");
82
+ assertEquals(e.answeredAt, "2026-03-01T00:50:00Z");
83
+ });
84
+
85
+ test("resolves the PR by processKey when no prKey is given", async () => {
86
+ const res = (await handler(input({ processKey: "proc-7" }), escalatedThenResumed())) as Row;
87
+ assertEquals(res.status, 200);
88
+ assertEquals(res.body.prKey, "o/r#7");
89
+ assertEquals(res.body.rounds.length, 2);
90
+ assertEquals(res.body.escalations.length, 1);
91
+ });
92
+
93
+ test("orders rounds by round_no and escalations in asked order", async () => {
94
+ const app = memApp({
95
+ rounds: [
96
+ { id: 2, pr_key: "o/r#9", round_no: 2, status: "converged", summary: null, worker: null, started_at: "b", ended_at: null },
97
+ { id: 1, pr_key: "o/r#9", round_no: 1, status: "addressed", summary: null, worker: null, started_at: "a", ended_at: null },
98
+ ],
99
+ escalations: [
100
+ { id: 5, pr_key: "o/r#9", round_no: 2, kind: "question", question: "second", answer: null, status: "open", worker: null, asked_at: "b", answered_at: null },
101
+ { id: 4, pr_key: "o/r#9", round_no: 1, kind: "question", question: "first", answer: null, status: "answered", worker: null, asked_at: "a", answered_at: "a2" },
102
+ ],
103
+ });
104
+ const res = (await handler(input({ prKey: "o/r#9" }), app)) as Row;
105
+ assertEquals(res.body.rounds.map((r: Row) => r.roundNo), [1, 2]);
106
+ assertEquals(res.body.escalations.map((e: Row) => e.question), ["first", "second"]);
107
+ });
108
+
109
+ test("an unknown PR returns an empty history (not a 404)", async () => {
110
+ const res = (await handler(input({ prKey: "o/r#404" }), memApp({}))) as Row;
111
+ assertEquals(res.status, 200);
112
+ assertEquals(res.body.prKey, "o/r#404");
113
+ assertEquals(res.body.rounds, []);
114
+ assertEquals(res.body.escalations, []);
115
+ });
116
+
117
+ test("neither prKey nor processKey → 400", async () => {
118
+ const res = (await handler(input({}), memApp({}))) as Row;
119
+ assertEquals(res.status, 400);
120
+ });
121
+
122
+ test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
123
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
124
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
125
+ try {
126
+ const mod = await import(`./getPrHistory.ts?guard=${Date.now()}`);
127
+ const guarded = mod.default as typeof handler;
128
+ const bad = (await guarded(input({ prKey: "o/r#7" }), escalatedThenResumed())) as Row;
129
+ assertEquals(bad.status, 401);
130
+ const ok = (await guarded(input({ prKey: "o/r#7" }, { "x-hook-secret": "s3cr3t" }), escalatedThenResumed())) as Row;
131
+ assertEquals(ok.status, 200);
132
+ assert(Array.isArray(ok.body.rounds));
133
+ } finally {
134
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
135
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
136
+ }
137
+ });
@@ -0,0 +1,41 @@
1
+ // GET /app/api/prs/history → operationId `getPrHistory` (issue #668, N4 of epic #664). Surface a PR's
2
+ // escalation + round history — "why did this escalate?" / "what happened in prior rounds?" — as an
3
+ // MCP read tool, so an operator or external harness no longer has to ssh into the instance and query
4
+ // the `escalations`/`rounds` tables by hand.
5
+ //
6
+ // Read-only projection over the SAME `rounds`/`escalations` tables the Convergence page's PR detail
7
+ // reads (see app/prHistory.ts). Identify the PR by `prKey` directly, or by `processKey` (resolved to
8
+ // its pr_key). At least one is required; neither → 400.
9
+ //
10
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`):
11
+ // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header.
12
+ import { prHistory, prKeyForProcess } from "../app/prHistory.ts";
13
+ import { envVar } from "../app/version.ts";
14
+ import { defineOperation } from "../nano-generated/operations.ts";
15
+
16
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
17
+
18
+ export default defineOperation("getPrHistory", async ({ query, req }, app) => {
19
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
20
+ app.log.warn("getPrHistory rejected: missing/invalid shared secret");
21
+ return { status: 401, body: { error: "unauthorized" } };
22
+ }
23
+ const rawPrKey = query.prKey;
24
+ const rawProcessKey = query.processKey;
25
+ const prKeyArg = typeof rawPrKey === "string" ? rawPrKey.trim() : "";
26
+ const processKeyArg = typeof rawProcessKey === "string" ? rawProcessKey.trim() : "";
27
+
28
+ if (!prKeyArg && !processKeyArg) {
29
+ return { status: 400, body: { error: "prKey or processKey is required" } };
30
+ }
31
+
32
+ const prKey = prKeyArg || (await prKeyForProcess(app.data, processKeyArg));
33
+ if (!prKey) {
34
+ // A processKey with no tracked PR: report an empty history for a stable, echoable identity
35
+ // rather than a 404, mirroring getLineage's "empty if unknown" read semantics.
36
+ return { status: 200, body: { prKey: processKeyArg, rounds: [], escalations: [] } };
37
+ }
38
+
39
+ const history = await prHistory(app.data, prKey);
40
+ return { status: 200, body: history };
41
+ });
@@ -8,14 +8,15 @@ import { noopLog } from "../test/log.ts";
8
8
  import { withTrackingViews } from "../test/trackingViews.ts";
9
9
  import handler from "./listActivePrs.ts";
10
10
 
11
- function memApp(rows: any[], escalations: any[] = []): AppApi {
11
+ function memApp(rows: any[], userTaskRows: any[] = []): AppApi {
12
12
  const table = (name: string) => {
13
- if (name === "escalations") {
13
+ if (name === "user_tasks") {
14
14
  return {
15
+ async all() {
16
+ return userTaskRows;
17
+ },
15
18
  async find(where: Record<string, unknown>) {
16
- return escalations.filter((e) =>
17
- Object.entries(where).every(([k, v]) => e[k] === v)
18
- );
19
+ return userTaskRows.filter((t) => Object.entries(where).every(([k, v]) => t[k] === v));
19
20
  },
20
21
  };
21
22
  }
@@ -58,25 +59,42 @@ test("returns 200 with a count + projected active PRs", async () => {
58
59
  assertEquals(r.body.prs[0].processKey, "9");
59
60
  });
60
61
 
61
- test("surfaces openEscalation for an escalated PR from its open escalations row (both loops)", async () => {
62
- // Regression: a merge-loop escalation parks on a message catch (no user task), so deriving
63
- // openEscalation from a user-task probe hid it. Deriving from the canonical `escalations` row
64
- // surfaces it. Two escalated PRs one with an open row (visible), one already answered (null).
62
+ test("surfaces the structured openEscalation from the user_tasks read model (both loops)", async () => {
63
+ // Issue #666: openEscalation is derived from the ONE `user_tasks` read model (the same surface
64
+ // `listEscalations` and the Convergence page consume), so `/status` carries the completable
65
+ // userTaskKey. A `user_tasks` row for a PR subject exists iff its review/merge-loop escalation task
66
+ // is currently open; a PR with no such row derives null. Cover a review-loop and a merge-loop PR.
65
67
  const app = memApp(
66
68
  [
67
69
  { pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
68
- { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "answered", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
70
+ { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "no open task", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
69
71
  ],
70
72
  [
71
- { id: 1, pr_key: "o/r#10", status: "open", question: "Resolve the conflict on the branch, then retry?" },
72
- { id: 2, pr_key: "o/r#11", status: "answered", question: "old question" },
73
+ {
74
+ user_task_key: "ut-10",
75
+ element_id: "wait-merge-answer",
76
+ kind_label: "PR merge",
77
+ subject_type: "pr",
78
+ subject_key: "o/r#10",
79
+ subject_title: "merge blocked",
80
+ subject_url: null,
81
+ question: "Resolve the conflict on the branch, then retry?",
82
+ process_key: "m1",
83
+ form_key: null,
84
+ created_at: "2026-02-02",
85
+ updated_at: "2026-02-02",
86
+ },
73
87
  ],
74
88
  );
75
89
  const res = (await handler(input(), app)) as any;
76
90
  assertEquals(res.status, 200);
77
91
  const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
78
92
  const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
79
- assertEquals(p10.openEscalation, "Resolve the conflict on the branch, then retry?");
93
+ assertEquals(p10.openEscalation, {
94
+ userTaskKey: "ut-10",
95
+ kind: "wait-merge-answer",
96
+ summary: "Resolve the conflict on the branch, then retry?",
97
+ });
80
98
  assertEquals(p11.openEscalation, null);
81
99
  });
82
100