@nanobpm/nano-workforce 0.56.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 +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- 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/github.test.ts +179 -1
- package/app/github.ts +132 -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 +267 -313
- package/app/plan.ts +173 -231
- 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 +41 -11
- package/openapi.yaml +237 -84
- 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 +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- 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 +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- 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 +322 -222
- 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/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -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
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
|
|
@@ -361,9 +374,10 @@ components:
|
|
|
361
374
|
per-request review-only override; defaults to false (the global auto-merge default applies).
|
|
362
375
|
PlanStart:
|
|
363
376
|
description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
|
|
364
|
-
`issue` (an `owner/repo#123` reference) or `url` (a bare issue URL)
|
|
365
|
-
|
|
366
|
-
|
|
377
|
+
`issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a REQUIRED
|
|
378
|
+
`baseBranch` (ADR 0003) the fleet branches off — a blank/absent base is a 400. Modeled as
|
|
379
|
+
`oneOf` named variants (Camunda REST v2 pattern) so an ambiguous or empty target is a 400 at
|
|
380
|
+
the edge, not a silent `issue ?? url` coalesce in the delegate.
|
|
367
381
|
oneOf:
|
|
368
382
|
- $ref: "#/components/schemas/PlanStartByIssue"
|
|
369
383
|
- $ref: "#/components/schemas/PlanStartByUrl"
|
|
@@ -372,105 +386,215 @@ components:
|
|
|
372
386
|
additionalProperties: false
|
|
373
387
|
required:
|
|
374
388
|
- issue
|
|
389
|
+
- baseBranch
|
|
375
390
|
properties:
|
|
376
391
|
issue:
|
|
377
392
|
type: string
|
|
378
393
|
description: "Issue reference: owner/repo#123."
|
|
379
394
|
baseBranch:
|
|
380
395
|
type: string
|
|
396
|
+
minLength: 1
|
|
397
|
+
maxLength: 255
|
|
398
|
+
pattern: '\S'
|
|
399
|
+
description: >-
|
|
400
|
+
REQUIRED target branch the fleet branches off and opens every PR against. Every epic
|
|
401
|
+
launch must name its base explicitly (ADR 0003): a blank/absent value is rejected with a
|
|
402
|
+
400, not silently coalesced to the repository default branch. Use it to land an entire
|
|
403
|
+
epic on a long-lived integration branch (e.g. `epic/agent-protocol`) so nothing reaches
|
|
404
|
+
the default branch — and any merge-to-default side effect, such as auto-publishing a
|
|
405
|
+
package — until you deliberately merge the integration branch. NOTE: this slice (B0)
|
|
406
|
+
only enforces that `baseBranch` is present and a plausible branch name; branch-existence
|
|
407
|
+
admission (auto-creating a missing `epic/*` base off the default branch HEAD, and
|
|
408
|
+
rejecting a missing non-`epic/*` base with a 400) is specified by ADR 0003 but NOT yet
|
|
409
|
+
enforced here — it lands in a later admission slice.
|
|
410
|
+
allowSharedBase:
|
|
411
|
+
type: boolean
|
|
381
412
|
description: >-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
413
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0). When
|
|
414
|
+
implemented it will opt in to sharing a custom integration base branch with another
|
|
415
|
+
already-active plan: admission will otherwise reject (409) when another active plan already
|
|
416
|
+
targets the same repo + same custom base branch, to stop two epics interleaving commits on
|
|
417
|
+
one integration branch (the repository default branch is exempt from that guard). Accepted
|
|
418
|
+
by the schema today but currently has no runtime effect.
|
|
419
|
+
confirmDefaultBase:
|
|
420
|
+
type: boolean
|
|
421
|
+
description: >-
|
|
422
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0). When
|
|
423
|
+
implemented it will be the required acknowledgement when `baseBranch` names the repository
|
|
424
|
+
default branch: targeting the default lands every task directly on it with no integration
|
|
425
|
+
buffer — and fires any merge-to-default side effect per task — so admission will reject
|
|
426
|
+
(400) unless confirmed with true (no effect for a non-default base). Accepted by the schema
|
|
427
|
+
today but currently has no runtime effect.
|
|
388
428
|
PlanStartByUrl:
|
|
389
429
|
type: object
|
|
390
430
|
additionalProperties: false
|
|
391
431
|
required:
|
|
392
432
|
- url
|
|
433
|
+
- baseBranch
|
|
393
434
|
properties:
|
|
394
435
|
url:
|
|
395
436
|
type: string
|
|
396
437
|
description: A bare issue URL, when no `owner/repo#123` reference is supplied.
|
|
397
438
|
baseBranch:
|
|
398
439
|
type: string
|
|
440
|
+
minLength: 1
|
|
441
|
+
maxLength: 255
|
|
442
|
+
pattern: '\S'
|
|
399
443
|
description: >-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
ok:
|
|
444
|
+
REQUIRED target branch the fleet branches off and opens every PR against. See
|
|
445
|
+
`PlanStartByIssue.baseBranch`.
|
|
446
|
+
allowSharedBase:
|
|
447
|
+
type: boolean
|
|
448
|
+
description: >-
|
|
449
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0).
|
|
450
|
+
Accepted by the schema today but currently has no runtime effect. When implemented it
|
|
451
|
+
will opt in to sharing a custom integration base branch with another already-active plan.
|
|
452
|
+
See `PlanStartByIssue.allowSharedBase`.
|
|
453
|
+
confirmDefaultBase:
|
|
411
454
|
type: boolean
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
455
|
+
description: >-
|
|
456
|
+
Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0).
|
|
457
|
+
Accepted by the schema today but currently has no runtime effect. When implemented it
|
|
458
|
+
will be the required acknowledgement when `baseBranch` names the repository default
|
|
459
|
+
branch. See `PlanStartByIssue.confirmDefaultBase`.
|
|
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.
|
|
418
466
|
oneOf:
|
|
419
|
-
- $ref: "#/components/schemas/
|
|
420
|
-
- $ref: "#/components/schemas/
|
|
421
|
-
|
|
467
|
+
- $ref: "#/components/schemas/FeatureStartByIssue"
|
|
468
|
+
- $ref: "#/components/schemas/FeatureStartByUrl"
|
|
469
|
+
FeatureStartByIssue:
|
|
422
470
|
type: object
|
|
423
471
|
additionalProperties: false
|
|
424
472
|
required:
|
|
425
|
-
-
|
|
426
|
-
-
|
|
473
|
+
- issue
|
|
474
|
+
- baseBranch
|
|
427
475
|
properties:
|
|
428
|
-
|
|
476
|
+
issue:
|
|
429
477
|
type: string
|
|
430
|
-
description:
|
|
431
|
-
|
|
478
|
+
description: "Issue reference: owner/repo#123."
|
|
479
|
+
baseBranch:
|
|
432
480
|
type: string
|
|
433
481
|
minLength: 1
|
|
434
|
-
|
|
435
|
-
|
|
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:
|
|
490
|
+
type: boolean
|
|
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:
|
|
436
511
|
type: object
|
|
437
512
|
additionalProperties: false
|
|
438
513
|
required:
|
|
439
|
-
-
|
|
440
|
-
-
|
|
441
|
-
- answer
|
|
514
|
+
- url
|
|
515
|
+
- baseBranch
|
|
442
516
|
properties:
|
|
443
|
-
|
|
444
|
-
type: string
|
|
445
|
-
description: Plan reference (owner/repo#N); combined with `task` to derive the corrKey.
|
|
446
|
-
task:
|
|
517
|
+
url:
|
|
447
518
|
type: string
|
|
448
|
-
description:
|
|
449
|
-
|
|
519
|
+
description: A bare issue URL, when no `owner/repo#123` reference is supplied.
|
|
520
|
+
baseBranch:
|
|
450
521
|
type: string
|
|
451
522
|
minLength: 1
|
|
452
|
-
|
|
453
|
-
|
|
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:
|
|
454
551
|
type: object
|
|
455
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.
|
|
456
558
|
required:
|
|
457
|
-
-
|
|
458
|
-
-
|
|
559
|
+
- userTaskKey
|
|
560
|
+
- agentId
|
|
561
|
+
- variables
|
|
459
562
|
properties:
|
|
460
|
-
|
|
563
|
+
userTaskKey:
|
|
461
564
|
type: string
|
|
462
|
-
|
|
463
|
-
|
|
565
|
+
minLength: 1
|
|
566
|
+
description: The engine user-task key of the parked escalation task (from GET /tasks/api/tasks).
|
|
567
|
+
agentId:
|
|
464
568
|
type: string
|
|
569
|
+
minLength: 1
|
|
570
|
+
description: The completing agent's identity (ADR 0046), recorded for attribution.
|
|
571
|
+
variables:
|
|
572
|
+
type: object
|
|
573
|
+
additionalProperties: true
|
|
465
574
|
description: >-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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:
|
|
578
|
+
type: object
|
|
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.
|
|
584
|
+
required:
|
|
585
|
+
- completionId
|
|
586
|
+
- reverterId
|
|
587
|
+
properties:
|
|
588
|
+
completionId:
|
|
589
|
+
type: integer
|
|
590
|
+
description: The `task_completions` id returned when the agent completion was recorded.
|
|
591
|
+
reverterId:
|
|
592
|
+
type: string
|
|
593
|
+
minLength: 1
|
|
594
|
+
description: The human identity overriding the agent completion, recorded for the audit trail.
|
|
471
595
|
note:
|
|
472
596
|
type: string
|
|
473
|
-
description:
|
|
597
|
+
description: Optional corrective guidance from the human that overrides the agent's answer.
|
|
474
598
|
BlackboardEntry:
|
|
475
599
|
type: object
|
|
476
600
|
additionalProperties: false
|
|
@@ -722,12 +846,43 @@ paths:
|
|
|
722
846
|
application/json:
|
|
723
847
|
schema:
|
|
724
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"
|
|
725
878
|
/actions/message:
|
|
726
879
|
post:
|
|
727
880
|
operationId: postMessage
|
|
728
|
-
summary: Publish a message / answer an escalation. For escalation-answered
|
|
729
|
-
|
|
730
|
-
|
|
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."
|
|
731
886
|
requestBody:
|
|
732
887
|
required: true
|
|
733
888
|
content:
|
|
@@ -750,13 +905,7 @@ paths:
|
|
|
750
905
|
properties:
|
|
751
906
|
answer:
|
|
752
907
|
type: string
|
|
753
|
-
|
|
754
|
-
type: string
|
|
755
|
-
description: >-
|
|
756
|
-
One of `proceed` or `revise` (case-insensitive; normalized to lowercase and
|
|
757
|
-
trimmed server-side, see `parsePlanEscalationDirective`).
|
|
758
|
-
note:
|
|
759
|
-
type: string
|
|
908
|
+
description: The operator's answer that resumes a parked merge-loop escalation.
|
|
760
909
|
responses:
|
|
761
910
|
"200":
|
|
762
911
|
description: The message was published (or the escalation answered).
|
|
@@ -776,11 +925,13 @@ paths:
|
|
|
776
925
|
application/json:
|
|
777
926
|
schema:
|
|
778
927
|
$ref: "#/components/schemas/MessageResult"
|
|
779
|
-
/hooks/
|
|
928
|
+
/hooks/agent-complete:
|
|
780
929
|
post:
|
|
781
|
-
operationId:
|
|
782
|
-
summary: "
|
|
783
|
-
|
|
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."
|
|
784
935
|
security:
|
|
785
936
|
- hookSecret: []
|
|
786
937
|
- {}
|
|
@@ -789,16 +940,16 @@ paths:
|
|
|
789
940
|
content:
|
|
790
941
|
application/json:
|
|
791
942
|
schema:
|
|
792
|
-
$ref: "#/components/schemas/
|
|
943
|
+
$ref: "#/components/schemas/AgentCompleteRequest"
|
|
793
944
|
responses:
|
|
794
945
|
"200":
|
|
795
|
-
description: The escalation
|
|
946
|
+
description: The agent completed the escalation task and the process resumed.
|
|
796
947
|
content:
|
|
797
948
|
application/json:
|
|
798
949
|
schema:
|
|
799
950
|
$ref: "#/components/schemas/MessageResult"
|
|
800
951
|
"400":
|
|
801
|
-
description: A required field was missing (
|
|
952
|
+
description: A required field was missing (userTaskKey, agentId, variables), or the target is not an escalation task.
|
|
802
953
|
content:
|
|
803
954
|
application/json:
|
|
804
955
|
schema:
|
|
@@ -810,16 +961,18 @@ paths:
|
|
|
810
961
|
schema:
|
|
811
962
|
$ref: "#/components/schemas/MessageResult"
|
|
812
963
|
"404":
|
|
813
|
-
description: No
|
|
964
|
+
description: No open escalation user task matches the userTaskKey.
|
|
814
965
|
content:
|
|
815
966
|
application/json:
|
|
816
967
|
schema:
|
|
817
968
|
$ref: "#/components/schemas/MessageResult"
|
|
818
|
-
/hooks/
|
|
969
|
+
/hooks/revert-completion:
|
|
819
970
|
post:
|
|
820
|
-
operationId:
|
|
821
|
-
summary: "
|
|
822
|
-
|
|
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."
|
|
823
976
|
security:
|
|
824
977
|
- hookSecret: []
|
|
825
978
|
- {}
|
|
@@ -828,16 +981,16 @@ paths:
|
|
|
828
981
|
content:
|
|
829
982
|
application/json:
|
|
830
983
|
schema:
|
|
831
|
-
$ref: "#/components/schemas/
|
|
984
|
+
$ref: "#/components/schemas/RevertCompletionRequest"
|
|
832
985
|
responses:
|
|
833
986
|
"200":
|
|
834
|
-
description: The
|
|
987
|
+
description: The agent completion was reverted.
|
|
835
988
|
content:
|
|
836
989
|
application/json:
|
|
837
990
|
schema:
|
|
838
991
|
$ref: "#/components/schemas/MessageResult"
|
|
839
992
|
"400":
|
|
840
|
-
description: A required field was missing
|
|
993
|
+
description: A required field was missing, or the completion is not reversible / already reverted.
|
|
841
994
|
content:
|
|
842
995
|
application/json:
|
|
843
996
|
schema:
|
|
@@ -849,7 +1002,7 @@ paths:
|
|
|
849
1002
|
schema:
|
|
850
1003
|
$ref: "#/components/schemas/MessageResult"
|
|
851
1004
|
"404":
|
|
852
|
-
description: No
|
|
1005
|
+
description: No completion matches the completionId.
|
|
853
1006
|
content:
|
|
854
1007
|
application/json:
|
|
855
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
|
+
});
|
|
@@ -7,13 +7,24 @@ import type { AppApi } from "@nanobpm/urban";
|
|
|
7
7
|
import { noopLog } from "../test/log.ts";
|
|
8
8
|
import handler from "./listActivePrs.ts";
|
|
9
9
|
|
|
10
|
-
function memApp(rows: any[]): AppApi {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
return
|
|
14
|
-
|
|
10
|
+
function memApp(rows: any[], escalations: any[] = []): AppApi {
|
|
11
|
+
const table = (name: string) => {
|
|
12
|
+
if (name === "escalations") {
|
|
13
|
+
return {
|
|
14
|
+
async find(where: Record<string, unknown>) {
|
|
15
|
+
return escalations.filter((e) =>
|
|
16
|
+
Object.entries(where).every(([k, v]) => e[k] === v)
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
async all() {
|
|
23
|
+
return rows;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
15
26
|
};
|
|
16
|
-
return { data: { table
|
|
27
|
+
return { data: { table }, log: noopLog() } as any as AppApi;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
function input(headers: Record<string, string> = {}) {
|
|
@@ -46,6 +57,28 @@ test("returns 200 with a count + projected active PRs", async () => {
|
|
|
46
57
|
assertEquals(r.body.prs[0].processKey, "9");
|
|
47
58
|
});
|
|
48
59
|
|
|
60
|
+
test("surfaces openEscalation for an escalated PR from its open escalations row (both loops)", async () => {
|
|
61
|
+
// Regression: a merge-loop escalation parks on a message catch (no user task), so deriving
|
|
62
|
+
// openEscalation from a user-task probe hid it. Deriving from the canonical `escalations` row
|
|
63
|
+
// surfaces it. Two escalated PRs — one with an open row (visible), one already answered (null).
|
|
64
|
+
const app = memApp(
|
|
65
|
+
[
|
|
66
|
+
{ pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
|
|
67
|
+
{ pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "answered", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
{ id: 1, pr_key: "o/r#10", status: "open", question: "Resolve the conflict on the branch, then retry?" },
|
|
71
|
+
{ id: 2, pr_key: "o/r#11", status: "answered", question: "old question" },
|
|
72
|
+
],
|
|
73
|
+
);
|
|
74
|
+
const res = (await handler(input(), app)) as any;
|
|
75
|
+
assertEquals(res.status, 200);
|
|
76
|
+
const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
|
|
77
|
+
const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
|
|
78
|
+
assertEquals(p10.openEscalation, "Resolve the conflict on the branch, then retry?");
|
|
79
|
+
assertEquals(p11.openEscalation, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
49
82
|
test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
50
83
|
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
51
84
|
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
// POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
|
|
2
2
|
// Replaces the hand-rolled action that overrode the generic publishMessage action. For the
|
|
3
|
-
// `escalation-answered` message we run the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// merge-loop `escalation-answered` message we run the merge-loop escalation answer flow; any other
|
|
4
|
+
// message falls back to a plain publishMessage.
|
|
5
|
+
//
|
|
6
|
+
// The four #156 escalation kinds (task, plan-review, trial-merge, PR review-loop) are now native
|
|
7
|
+
// `userTask`s answered directly through the task inbox (`POST /tasks/api/complete`), so this
|
|
8
|
+
// delegate no longer carries their bespoke `feature-escalation-answered` / `plan-escalation-answered`
|
|
9
|
+
// discriminators. The merge-loop escalation is still a durable message catch (out of scope for
|
|
10
|
+
// #156), so its `escalation-answered` branch is kept.
|
|
7
11
|
//
|
|
8
12
|
// The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
|
|
9
13
|
// for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
|
|
10
14
|
// is app logic, not something the JSON schema can express.
|
|
11
15
|
|
|
12
|
-
import {
|
|
13
|
-
answerPlanEscalation,
|
|
14
|
-
answerTaskEscalation,
|
|
15
|
-
FEATURE_ESCALATION_MESSAGE,
|
|
16
|
-
PLAN_ESCALATION_MESSAGE,
|
|
17
|
-
parsePlanEscalationDirective,
|
|
18
|
-
} from "../app/plan.ts";
|
|
19
16
|
import { answerEscalation } from "../app/service.ts";
|
|
20
17
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
21
18
|
|
|
@@ -33,36 +30,8 @@ export default defineOperation("postMessage", async ({ body }, app) => {
|
|
|
33
30
|
if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
34
31
|
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
35
32
|
const r = await answerEscalation(app.data, app.engine, prKey, answer);
|
|
36
|
-
if (r.ok) app.log.info("
|
|
37
|
-
else app.log.warn("postMessage: no open
|
|
38
|
-
return { status: r.ok ? 200 : 404, body: r };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (name === FEATURE_ESCALATION_MESSAGE) {
|
|
42
|
-
// Implementation-phase task escalation (issue #25): correlationKey is the task's
|
|
43
|
-
// `<plan_key>:<task_id>`; record the answer, resume the parked child, and re-surface the next
|
|
44
|
-
// open escalation.
|
|
45
|
-
const corrKey = String(b.correlationKey ?? "");
|
|
46
|
-
const answer = String(b.variables?.answer ?? "").trim();
|
|
47
|
-
if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
48
|
-
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
49
|
-
const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
|
|
50
|
-
if (r.ok) app.log.info("feature escalation answered", { name, corrKey });
|
|
51
|
-
else app.log.warn("postMessage: no open feature escalation to answer", { name, corrKey });
|
|
52
|
-
return { status: r.ok ? 200 : 404, body: r };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (name === PLAN_ESCALATION_MESSAGE) {
|
|
56
|
-
const planKey = String(b.correlationKey ?? "");
|
|
57
|
-
const directive = parsePlanEscalationDirective(b.variables?.directive ?? "revise");
|
|
58
|
-
const note = String(b.variables?.note ?? b.variables?.answer ?? "").trim();
|
|
59
|
-
if (!planKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
60
|
-
if (!directive) {
|
|
61
|
-
return { status: 400, body: { error: "directive must be proceed or revise" } };
|
|
62
|
-
}
|
|
63
|
-
const r = await answerPlanEscalation(app.data, app.engine, planKey, directive, note);
|
|
64
|
-
if (r.ok) app.log.info("plan escalation answered", { name, planKey, directive });
|
|
65
|
-
else app.log.warn("postMessage: no open plan escalation to answer", { name, planKey });
|
|
33
|
+
if (r.ok) app.log.info("merge-loop escalation answered", { name, prKey });
|
|
34
|
+
else app.log.warn("postMessage: no open merge-loop escalation to answer", { name, prKey });
|
|
66
35
|
return { status: r.ok ? 200 : 404, body: r };
|
|
67
36
|
}
|
|
68
37
|
|