@nanobpm/nano-workforce 0.167.0 → 0.167.2
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/mirror-install-dispatch.yml +54 -0
- package/CHANGELOG.md +13 -0
- package/app/contracts.ts +17 -1
- package/app/pollUserTasks.test.ts +165 -0
- package/app/pullRequestReadModel.ts +6 -1
- package/app/service.ts +52 -0
- package/e2e/feature-preflight.e2e.ts +3 -2
- package/e2e/feature-run.e2e.ts +60 -5
- package/nano.app.json +4 -0
- package/operations/acknowledgePr.test.ts +64 -19
- package/operations/acknowledgePr.ts +34 -16
- package/package.json +2 -2
- package/pages/delivery-graphs/library.mount.js +59 -2
- package/pages/delivery-graphs/mount.js +88 -2
- package/resources/processes/feature.bpmn +90 -69
- package/test/delivery-graphs-ack-or-timeout.test.ts +280 -0
- package/test/delivery-graphs-library-embed.test.ts +1 -1
- package/workers/record-feature-implementing/worker.test.ts +57 -0
- package/workers/record-feature-implementing/worker.ts +31 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Refresh the mirrored install script on nanobpm.io the moment install.sh changes.
|
|
2
|
+
#
|
|
3
|
+
# https://nanobpm.io/install.sh is served as the ACTUAL bytes of THIS repo's
|
|
4
|
+
# install.sh (GitHub Pages can't 301-redirect a `curl … | sh` URL). The site is
|
|
5
|
+
# built in a DIFFERENT repo (Magikcraft/nano-bpm) whose Pages workflow re-fetches
|
|
6
|
+
# install.sh on every deploy — but that deploy only triggers on changes to files
|
|
7
|
+
# in that repo, so an edit here would otherwise not reach the published mirror
|
|
8
|
+
# until the nightly backstop cron. This job pokes that repo's Pages deploy via a
|
|
9
|
+
# `repository_dispatch` (event type `install-sh-updated`) so the mirror refreshes
|
|
10
|
+
# within minutes of merging an install.sh change.
|
|
11
|
+
#
|
|
12
|
+
# Requires a token that can write to Magikcraft/nano-bpm — the default
|
|
13
|
+
# GITHUB_TOKEN is scoped to THIS repo only. Store a fine-grained PAT (or GitHub
|
|
14
|
+
# App token) with `Contents: read and write` on Magikcraft/nano-bpm as the repo
|
|
15
|
+
# secret PAGES_DISPATCH_TOKEN. Absent the secret, the job no-ops (the nightly
|
|
16
|
+
# cron on the site keeps the mirror eventually-consistent).
|
|
17
|
+
name: Refresh nanobpm.io install mirror
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
push:
|
|
21
|
+
branches: [main]
|
|
22
|
+
paths:
|
|
23
|
+
- 'install.sh'
|
|
24
|
+
workflow_dispatch:
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
dispatch:
|
|
31
|
+
name: dispatch install-sh-updated
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- name: Notify the nanobpm.io Pages deploy
|
|
35
|
+
env:
|
|
36
|
+
DISPATCH_TOKEN: ${{ secrets.PAGES_DISPATCH_TOKEN }}
|
|
37
|
+
run: |
|
|
38
|
+
set -eu
|
|
39
|
+
if [ -z "${DISPATCH_TOKEN:-}" ]; then
|
|
40
|
+
echo "::warning::PAGES_DISPATCH_TOKEN is not set — skipping. nanobpm.io/install.sh will refresh on its nightly cron instead."
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
|
44
|
+
-X POST \
|
|
45
|
+
-H 'Accept: application/vnd.github+json' \
|
|
46
|
+
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
|
|
47
|
+
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
|
48
|
+
https://api.github.com/repos/Magikcraft/nano-bpm/dispatches \
|
|
49
|
+
-d '{"event_type":"install-sh-updated"}')
|
|
50
|
+
if [ "$code" != "204" ]; then
|
|
51
|
+
echo "::error::repository_dispatch to Magikcraft/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
echo "dispatched install-sh-updated to Magikcraft/nano-bpm (HTTP 204)"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## [0.167.2](https://github.com/nanobpm/nano-workforce/compare/v0.167.1...v0.167.2) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* gate acknowledgePr on the read model's folded status, not the base column ([#653](https://github.com/nanobpm/nano-workforce/issues/653)) ([80446e2](https://github.com/nanobpm/nano-workforce/commit/80446e24af3c62570c041a109eef85eb34f8bb23)), closes [#652](https://github.com/nanobpm/nano-workforce/issues/652)
|
|
6
|
+
|
|
7
|
+
## [0.167.1](https://github.com/nanobpm/nano-workforce/compare/v0.167.0...v0.167.1) (2026-08-31)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **delivery-graphs:** honest Preview & Reuse toasts (drop optimistic ✓, ack-or-timeout, bump @nanobpm/urban) ([#645](https://github.com/nanobpm/nano-workforce/issues/645)) ([#650](https://github.com/nanobpm/nano-workforce/issues/650)) ([b9cd357](https://github.com/nanobpm/nano-workforce/commit/b9cd357335fc555224bb35c5043b5d5df9cc273d)), closes [#518](https://github.com/nanobpm/nano-workforce/issues/518) [#518](https://github.com/nanobpm/nano-workforce/issues/518)
|
|
12
|
+
* **feature:** reset status to running on answer re-entry to implement-task ([#642](https://github.com/nanobpm/nano-workforce/issues/642)) ([#647](https://github.com/nanobpm/nano-workforce/issues/647)) ([fd636ba](https://github.com/nanobpm/nano-workforce/commit/fd636ba482562269ab64a242c5f29d5c4b1e2020)), closes [#632](https://github.com/nanobpm/nano-workforce/issues/632)
|
|
13
|
+
|
|
1
14
|
## [0.167.0](https://github.com/nanobpm/nano-workforce/compare/v0.166.0...v0.167.0) (2026-08-31)
|
|
2
15
|
|
|
3
16
|
### Features
|
package/app/contracts.ts
CHANGED
|
@@ -420,7 +420,23 @@ export const WIRE_CONTRACTS = {
|
|
|
420
420
|
owner: "pages/delivery-graphs/mount.js",
|
|
421
421
|
semantics:
|
|
422
422
|
"The INBOUND reuse-fill host-bridge message that loads a saved `DeliveryGraph` JSON into the Delivery Graphs COMPOSE App-View textarea (`#dg-json`) — issue #523, epic #519 S4. The compose mount (consumer) registers a same-origin `window` `message` listener for this shape and routes it through its single `fillComposer()` seam; the producer is the Library App-View **Reuse** action (#523), which posts it across the App-View iframe boundary (the INBOUND twin of the existing OUTBOUND `nano-navigate` DI-preview bridge). The filesystem **Import** control (#524) is NOT a producer of this message — it lives in the same compose mount and fills directly through `fillComposer()`, no cross-frame hop. The `type` string is exported ONCE as `DG_COMPOSE_FILL_MESSAGE` from pages/delivery-graphs/mount.js — the Reuse producer imports it, never re-declares a synonym.",
|
|
423
|
-
shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string }',
|
|
423
|
+
shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string, token?: string }',
|
|
424
|
+
},
|
|
425
|
+
"deliveryGraph.compose.fill.ack": {
|
|
426
|
+
category: "wire",
|
|
427
|
+
name: "deliveryGraph.compose.fill.ack",
|
|
428
|
+
owner: "pages/delivery-graphs/mount.js",
|
|
429
|
+
semantics:
|
|
430
|
+
"The ACK half of the reuse-fill host-bridge message (issue #645). The compose App-View posts it back UP to the host — relayed across to the Library sibling App-View by the Urban App-View relay (nano-ide #518) — the moment it has actually filled `#dg-json` from a `deliveryGraph.compose.fill`. It exists to make the Library's success toast EARNED, not optimistic: the Library shows \"✓ Loaded…\" ONLY on this ack (matching its correlation `token`) and a clear \"Couldn't reach the composer\" on a short timeout, closing the #645 false-positive-toast defect. Its `token` echoes the producer's fill `token` so a stale ack from a prior Reuse can't complete a newer one. The `type` string is exported ONCE as `DG_COMPOSE_FILL_ACK_MESSAGE` from pages/delivery-graphs/mount.js — the Library consumer imports it, never re-declares a synonym.",
|
|
431
|
+
shape: '{ type: "nano-delivery-graph-compose-fill-ack", token: string | null }',
|
|
432
|
+
},
|
|
433
|
+
"nano.navigate.ack": {
|
|
434
|
+
category: "wire",
|
|
435
|
+
name: "nano.navigate.ack",
|
|
436
|
+
owner: "pages/delivery-graphs/mount.js",
|
|
437
|
+
semantics:
|
|
438
|
+
"The host's acknowledgment of a `nano-navigate` (issue #645). \"Preview generated DI\" posts `nano-navigate` UP to the console (forwarded to the host explorer by the Urban App-View relay, nano-ide #518), but the host does not synchronously confirm it navigated — so the old \"✓ Opening…\" toast printed right after the post claimed success even when the message was dropped (standalone, no relay, a console that never navigated). The compose view now treats Preview as fire-to-host with a bounded budget: a NEUTRAL in-progress status, resolved to \"✓ Opened…\" ONLY on this same-origin ack from the parent for the matching `target`, or to \"Couldn't reach the console explorer\" on a short timeout. Consumed same-origin from `window.parent`; the `type` string is `NANO_NAVIGATE_ACK_MESSAGE` in pages/delivery-graphs/mount.js.",
|
|
439
|
+
shape: '{ type: "nano-navigate-ack", target: string }',
|
|
424
440
|
},
|
|
425
441
|
"deliveryGraph.library.import.submit": {
|
|
426
442
|
category: "wire",
|
|
@@ -675,3 +675,168 @@ test("pollUserTasks (typed-seam fallback): denormalises the engine form_key from
|
|
|
675
675
|
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
676
676
|
assertEquals(byKey["ut-plan"].form_key, "form-77");
|
|
677
677
|
});
|
|
678
|
+
|
|
679
|
+
test("pollUserTasks (engine-first): self-heals an escalated run stranded off its parked task, sparing a genuinely parked one (issue #642)", async () => {
|
|
680
|
+
// `status="escalated"` must hold ONLY while a `feature-escalation` task is open (parity with the PR
|
|
681
|
+
// contract). A run whose instance the engine no longer reports parked (its escalation was answered,
|
|
682
|
+
// or it predates the write-side reset — the #632 tear) is reconciled to `running`; a run whose task
|
|
683
|
+
// IS still open is left escalated. The engine's open set is the authority, not the raw status column.
|
|
684
|
+
const { data, stores } = memData({
|
|
685
|
+
feature_runs: [
|
|
686
|
+
{ feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
|
|
687
|
+
{ feature_key: "o/r#77", status: "escalated", process_key: "fp-77", issue_url: null, title: "still parked", delivery_label: null },
|
|
688
|
+
],
|
|
689
|
+
});
|
|
690
|
+
const restore = stubUserTaskSearch([
|
|
691
|
+
// Only fp-77 is genuinely parked; the engine reports NO open task on fp-632.
|
|
692
|
+
{ userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-77", state: "CREATED" },
|
|
693
|
+
]);
|
|
694
|
+
try {
|
|
695
|
+
await pollUserTasks(data, fakeEngine({ "fp-77": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] }), REST);
|
|
696
|
+
} finally {
|
|
697
|
+
restore();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
701
|
+
assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
|
|
702
|
+
assertEquals(byKey["o/r#77"].status, "escalated", "the genuinely parked run stays escalated");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("pollUserTasks (engine-first): skips the per-instance open-task RPC for an escalated run already seen parked in this pass's sweep (issue #642)", async () => {
|
|
706
|
+
// Presence in THIS pass's swept `desired` set is POSITIVE evidence the run is genuinely parked — the
|
|
707
|
+
// best-effort sweep may truncate (drop tasks) but never invents one. Re-confirming such a run with a
|
|
708
|
+
// per-instance `openUserTasks` RPC is a redundant N+1 query on every poll tick; the self-heal must skip
|
|
709
|
+
// it. Only a run NOT confirmed parked by the sweep still needs the per-instance check (unchanged).
|
|
710
|
+
const { data, stores } = memData({
|
|
711
|
+
feature_runs: [
|
|
712
|
+
{ feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "parked", delivery_label: null },
|
|
713
|
+
{ feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
|
|
714
|
+
],
|
|
715
|
+
});
|
|
716
|
+
const restore = stubUserTaskSearch([
|
|
717
|
+
{ userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-parked", state: "CREATED" },
|
|
718
|
+
]);
|
|
719
|
+
const openUserTasksCalls: string[] = [];
|
|
720
|
+
const engine = {
|
|
721
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
722
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) => {
|
|
723
|
+
if (filter?.processInstanceKey) openUserTasksCalls.push(filter.processInstanceKey);
|
|
724
|
+
return Promise.resolve([]); // no instance reports an open escalation via the per-instance seam
|
|
725
|
+
},
|
|
726
|
+
} as unknown as EngineClient;
|
|
727
|
+
try {
|
|
728
|
+
await pollUserTasks(data, engine, REST);
|
|
729
|
+
} finally {
|
|
730
|
+
restore();
|
|
731
|
+
}
|
|
732
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
733
|
+
assertEquals(byKey["o/r#parked"].status, "escalated", "the swept-parked run stays escalated without a per-instance query");
|
|
734
|
+
assertEquals(byKey["o/r#stranded"].status, "running", "the run absent from the sweep is still confirmed per-instance and healed");
|
|
735
|
+
assertEquals(openUserTasksCalls.includes("fp-parked"), false, "no redundant per-instance RPC for the already-parked run");
|
|
736
|
+
assertEquals(openUserTasksCalls.includes("fp-stranded"), true, "the unconfirmed run still needs the per-instance RPC");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("pollUserTasks (engine-first): a TRUNCATED best-effort sweep never heals a genuinely-parked escalated run (issue #642)", async () => {
|
|
740
|
+
// `sweepOpenEscalationTasks` is explicitly best-effort — it BREAKS early on a paging/transport error
|
|
741
|
+
// and projects only what it had gathered. Healing `escalated -> running` on ABSENCE from that partial
|
|
742
|
+
// set is mutating durable state on negative evidence: a genuinely-parked human escalation whose task
|
|
743
|
+
// lived on an unreached page would be silently stolen. The self-heal must confirm per-instance against
|
|
744
|
+
// the engine's authoritative open set, NOT the (possibly truncated) global sweep.
|
|
745
|
+
const { data, stores } = memData({
|
|
746
|
+
feature_runs: [
|
|
747
|
+
{ feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "genuinely parked", delivery_label: null },
|
|
748
|
+
{ feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
|
|
749
|
+
],
|
|
750
|
+
});
|
|
751
|
+
// Page 1 fills the limit (forcing a second page) with unrelated open escalations; page 2 — which WOULD
|
|
752
|
+
// carry fp-parked's escalation — errors, so the sweep truncates and `desired` never sees fp-parked.
|
|
753
|
+
const page1: RawTask[] = Array.from({ length: 100 }, (_, i) => ({
|
|
754
|
+
userTaskKey: `other-${i}`,
|
|
755
|
+
elementId: "feature-escalation",
|
|
756
|
+
processInstanceKey: `other-${i}`,
|
|
757
|
+
state: "CREATED",
|
|
758
|
+
}));
|
|
759
|
+
const orig = globalThis.fetch;
|
|
760
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
|
|
761
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
762
|
+
if (!String(url).endsWith("/user-tasks/search")) return new Response("not found", { status: 404 });
|
|
763
|
+
const from: number = JSON.parse(init?.body ?? "{}")?.page?.from ?? 0;
|
|
764
|
+
if (from === 0) {
|
|
765
|
+
return new Response(JSON.stringify({ items: page1 }), { status: 200, headers: { "content-type": "application/json" } });
|
|
766
|
+
}
|
|
767
|
+
return new Response("boom", { status: 500 }); // page 2 transport error -> sweep truncates here
|
|
768
|
+
}) as typeof fetch;
|
|
769
|
+
// The engine's authoritative per-instance open set: fp-parked IS parked; fp-stranded is not.
|
|
770
|
+
const engine = fakeEngine({ "fp-parked": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] });
|
|
771
|
+
try {
|
|
772
|
+
await pollUserTasks(data, engine, REST);
|
|
773
|
+
} finally {
|
|
774
|
+
globalThis.fetch = orig;
|
|
775
|
+
}
|
|
776
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
777
|
+
assertEquals(byKey["o/r#parked"].status, "escalated", "a genuinely-parked run survives a truncated sweep");
|
|
778
|
+
assertEquals(byKey["o/r#stranded"].status, "running", "a truly stranded run is still healed");
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated run when the per-instance open-task query errors (issue #642)", async () => {
|
|
782
|
+
// A per-instance query error is not proof the run is unparked — mutating on that negative evidence would
|
|
783
|
+
// again steal a parked escalation. On query error the run must be left `escalated` for a later pass.
|
|
784
|
+
const { data, stores } = memData({
|
|
785
|
+
feature_runs: [{ feature_key: "o/r#err", status: "escalated", process_key: "fp-err", issue_url: null, title: "query errors", delivery_label: null }],
|
|
786
|
+
});
|
|
787
|
+
const restore = stubUserTaskSearch([]); // empty sweep -> old code would heal on absence
|
|
788
|
+
const engine = {
|
|
789
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
790
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
791
|
+
filter?.processInstanceKey === "fp-err" ? Promise.reject(new Error("engine down")) : Promise.resolve([]),
|
|
792
|
+
} as unknown as EngineClient;
|
|
793
|
+
try {
|
|
794
|
+
await pollUserTasks(data, engine, REST);
|
|
795
|
+
} finally {
|
|
796
|
+
restore();
|
|
797
|
+
}
|
|
798
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
799
|
+
assertEquals(byKey["o/r#err"].status, "escalated", "a failed per-instance query leaves the run escalated");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("pollUserTasks (engine-first): does NOT heal a JUST-escalated run inside the grace window before its user task exists (issue #642)", async () => {
|
|
803
|
+
// `record-feature-escalation` writes `status="escalated"` (stamping `updated_at`) on the `escalated`
|
|
804
|
+
// arm IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing in that
|
|
805
|
+
// window sees `openUserTasks` return none and would wrongly flip the fresh escalation back to `running`,
|
|
806
|
+
// making the just-raised escalation invisible. A short grace window on `updated_at` spares a just-written
|
|
807
|
+
// escalation while still healing genuinely-stranded (old) rows.
|
|
808
|
+
const fresh = new Date().toISOString();
|
|
809
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString(); // an hour ago — comfortably past grace
|
|
810
|
+
const { data, stores } = memData({
|
|
811
|
+
feature_runs: [
|
|
812
|
+
{ feature_key: "o/r#fresh", status: "escalated", process_key: "fp-fresh", updated_at: fresh, issue_url: null, title: "just escalated", delivery_label: null },
|
|
813
|
+
{ feature_key: "o/r#old", status: "escalated", process_key: "fp-old", updated_at: stale, issue_url: null, title: "genuinely stranded", delivery_label: null },
|
|
814
|
+
],
|
|
815
|
+
});
|
|
816
|
+
const restore = stubUserTaskSearch([]); // engine reports no open escalation task for either instance
|
|
817
|
+
const engine = fakeEngine({}); // openUserTasks returns [] for every instance (task not yet created / gone)
|
|
818
|
+
try {
|
|
819
|
+
await pollUserTasks(data, engine, REST);
|
|
820
|
+
} finally {
|
|
821
|
+
restore();
|
|
822
|
+
}
|
|
823
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
824
|
+
assertEquals(byKey["o/r#fresh"].status, "escalated", "a just-escalated run inside the grace window is spared the heal");
|
|
825
|
+
assertEquals(byKey["o/r#old"].status, "running", "a genuinely-stranded (old) run is still healed");
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no open feature-escalation task (issue #642)", async () => {
|
|
829
|
+
// The reduced-capability path scans FEATURE_ACTIVE_STATUSES instances (incl. `escalated`) directly,
|
|
830
|
+
// so the per-instance open-task read is just as authoritative for the self-heal.
|
|
831
|
+
const { data, stores } = memData({
|
|
832
|
+
feature_runs: [
|
|
833
|
+
{ feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
const engine = fakeEngine({ "fp-632": [] }); // instance active at implement-task, no open user task
|
|
837
|
+
|
|
838
|
+
await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
|
|
839
|
+
|
|
840
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
841
|
+
assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
|
|
842
|
+
});
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
import { defineReadModel, type Expr, type ReadModel } from "@nanobpm/urban";
|
|
23
23
|
import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
|
|
24
24
|
|
|
25
|
+
/** The read model's VIEW name — the single source of truth for the table an operation queries when it
|
|
26
|
+
* must gate on the folded/effective status (e.g. `acknowledgePr`, issue #652) rather than re-derive
|
|
27
|
+
* terminality off the frozen base `pull_requests.status` column. */
|
|
28
|
+
export const PULL_REQUEST_READ_MODEL_NAME = "pull_requests_read_model";
|
|
29
|
+
|
|
25
30
|
/** The base table the read model reads: the auto-provisioned `pull_requests__tracking` derived VIEW
|
|
26
31
|
* (ADR-0065), NOT the raw `pull_requests` table — so the status-classifying derivations read the
|
|
27
32
|
* terminal-folded `derived_status` and a terminated PR drops to History with no worker write. */
|
|
@@ -66,7 +71,7 @@ export type PullRequestReadModelDerivedColumn = (typeof PULL_REQUEST_READ_MODEL_
|
|
|
66
71
|
* generated from THIS single declaration.
|
|
67
72
|
*/
|
|
68
73
|
export const pullRequestReadModel: ReadModel = defineReadModel({
|
|
69
|
-
name:
|
|
74
|
+
name: PULL_REQUEST_READ_MODEL_NAME,
|
|
70
75
|
baseTable: PULL_REQUEST_READ_MODEL_BASE_TABLE,
|
|
71
76
|
selectBaseColumns: false,
|
|
72
77
|
derive: {
|
package/app/service.ts
CHANGED
|
@@ -198,6 +198,15 @@ export const MERGEABLE_WAIT_TIMEOUT = mergeableWaitTimeout(
|
|
|
198
198
|
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
199
199
|
export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
|
|
200
200
|
|
|
201
|
+
/** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
|
|
202
|
+
* writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
|
|
203
|
+
* `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
|
|
204
|
+
* in that window would see `openUserTasks` report none and wrongly heal the just-raised escalation back
|
|
205
|
+
* to `running`, hiding it until a future write. Only heal rows whose escalation is older than this
|
|
206
|
+
* window, by when the user task must already be observable — sparing the in-flight transition while
|
|
207
|
+
* still reconciling genuinely-stranded rows. */
|
|
208
|
+
export const FEATURE_ESCALATION_HEAL_GRACE_MS = 60_000;
|
|
209
|
+
|
|
201
210
|
/** Whether a converged PR is automatically driven to merge (the merge-loop). Default on; set
|
|
202
211
|
* `NANO_PR_AUTO_MERGE=0` to stop at `converged` (review-only mode). */
|
|
203
212
|
export const AUTO_MERGE = !["0", "false", "off", "no"].includes(
|
|
@@ -2677,6 +2686,49 @@ export async function pollUserTasks(
|
|
|
2677
2686
|
await userTasks(data).update(user_task_key, { ...patch, updated_at: at });
|
|
2678
2687
|
}
|
|
2679
2688
|
for (const key of deletes) await userTasks(data).delete(key);
|
|
2689
|
+
|
|
2690
|
+
// ── Self-heal: `escalated` holds ONLY while parked (issue #642) ──────────────────────────────────
|
|
2691
|
+
// `record-feature-escalation` is the sole writer of `status="escalated"`, but the answer loop-back
|
|
2692
|
+
// now stamps `running` (via `record-feature-implementing`). This read-side sweep heals any row that
|
|
2693
|
+
// is ALREADY stranded at `escalated` — e.g. runs that escalated before the write-side twin shipped
|
|
2694
|
+
// (#632), or any future missed transition — by reconciling against the ENGINE's authoritative
|
|
2695
|
+
// open-escalation set. But it must NOT decide on absence from THIS pass's `desired` set:
|
|
2696
|
+
// `sweepOpenEscalationTasks` is best-effort and breaks early on a paging/transport error, so a
|
|
2697
|
+
// truncated `desired` would wrongly flip a genuinely-parked run whose task lived on an unreached page.
|
|
2698
|
+
// Confirm each escalated run POSITIVELY, per-instance, against `openUserTasks` (which pins
|
|
2699
|
+
// `state:"CREATED"`), and skip healing when that query errors — durable state is mutated only on
|
|
2700
|
+
// positive evidence that no `feature-escalation` task is open for the instance (parity with the PR
|
|
2701
|
+
// `status="escalated"` contract). Confined to rows with a known `process_key` so an untracked instance
|
|
2702
|
+
// is never guessed at.
|
|
2703
|
+
//
|
|
2704
|
+
// Presence in THIS pass's `desired` set is itself POSITIVE evidence of parking (truncation only ever
|
|
2705
|
+
// DROPS tasks, never invents one), so a run whose `feature-escalation` task was already swept is
|
|
2706
|
+
// genuinely parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every
|
|
2707
|
+
// tick). Only a run NOT confirmed parked by the sweep falls through to the per-instance check below.
|
|
2708
|
+
const sweptParkedEscalations = new Set(
|
|
2709
|
+
desired.filter((r) => r.element_id === FEATURE_ESCALATION_ELEMENT && r.process_key).map((r) => r.process_key),
|
|
2710
|
+
);
|
|
2711
|
+
for (const run of await featureRuns(data).find({ status: "escalated" })) {
|
|
2712
|
+
if (!run.process_key) continue;
|
|
2713
|
+
if (sweptParkedEscalations.has(run.process_key)) continue; // already seen parked this pass — no RPC, no heal
|
|
2714
|
+
// A just-written escalation may not have its `feature-escalation` user task yet: the sole writer,
|
|
2715
|
+
// `record-feature-escalation`, stamps `updated_at` immediately BEFORE the engine creates the task.
|
|
2716
|
+
// Skip healing inside the grace window so this pass never races that transition and steals a fresh
|
|
2717
|
+
// escalation; a genuinely-stranded (old, or timestamp-less) row is past the window and still healed.
|
|
2718
|
+
const escalatedAt = Date.parse(run.updated_at ?? "");
|
|
2719
|
+
if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < FEATURE_ESCALATION_HEAL_GRACE_MS) continue;
|
|
2720
|
+
let openTasks: { elementId?: string }[];
|
|
2721
|
+
try {
|
|
2722
|
+
openTasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
2723
|
+
} catch (err) {
|
|
2724
|
+
// Negative evidence from a failed query is not proof the run is unparked — leave it for a later pass.
|
|
2725
|
+
console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
if (!openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT)) {
|
|
2729
|
+
await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2680
2732
|
}
|
|
2681
2733
|
|
|
2682
2734
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
@@ -157,7 +157,8 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
157
157
|
"the green gate leads into the fan-out head",
|
|
158
158
|
);
|
|
159
159
|
assert.ok(
|
|
160
|
-
flows.includes("ensure-base-branch->
|
|
160
|
+
flows.includes("ensure-base-branch->record-feature-implementing") &&
|
|
161
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
161
162
|
`the run reaches the implement agent only after the gate (flows: ${flows.join(", ")})`,
|
|
162
163
|
);
|
|
163
164
|
// A green probe never escalates.
|
|
@@ -189,7 +190,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
189
190
|
);
|
|
190
191
|
assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
|
|
191
192
|
assert.ok(
|
|
192
|
-
flows.includes("
|
|
193
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
193
194
|
"an ungated feature reaches the implement agent",
|
|
194
195
|
);
|
|
195
196
|
} finally {
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -245,8 +245,12 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
245
245
|
|
|
246
246
|
const flows = takenFlows(app);
|
|
247
247
|
assert.ok(
|
|
248
|
-
flows.includes("w_gw_answer->
|
|
249
|
-
`answer re-dispatched the
|
|
248
|
+
flows.includes("w_gw_answer->record-feature-implementing"),
|
|
249
|
+
`answer re-dispatched through the implementing-reset task (flows: ${flows.join(", ")})`,
|
|
250
|
+
);
|
|
251
|
+
assert.ok(
|
|
252
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
253
|
+
`the reset task re-enters the same implement task (flows: ${flows.join(", ")})`,
|
|
250
254
|
);
|
|
251
255
|
assert.ok(!flows.includes("w_gw_answer->record-feature"), "the abandon (default) flow was NOT taken");
|
|
252
256
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
@@ -254,6 +258,56 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
254
258
|
);
|
|
255
259
|
});
|
|
256
260
|
|
|
261
|
+
test("escalate + answer: the run reads `running` (not a stale escalated) through the post-answer re-implementation (issue #642)", async () => {
|
|
262
|
+
// The write-side twin of `record-feature-escalation`: `record-feature-implementing` sits on the
|
|
263
|
+
// answer loop-back and stamps `running` BEFORE implement-task re-runs, so `feature_runs.status` no
|
|
264
|
+
// longer holds a stale `escalated` for the entire re-implementation (the #632 tear). Assert the
|
|
265
|
+
// agent observes `running` at the moment it is re-dispatched. Boots inline (not `withApp`) so the
|
|
266
|
+
// re-implementation stub can read the row through `app.db`.
|
|
267
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-f642-"));
|
|
268
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
|
|
269
|
+
try {
|
|
270
|
+
let calls = 0;
|
|
271
|
+
let statusDuringReimpl: string | undefined;
|
|
272
|
+
await app.engine.registerWorker("senior:feature", async () => {
|
|
273
|
+
calls += 1;
|
|
274
|
+
if (calls === 1) {
|
|
275
|
+
return { status: "escalated", question: "Which API should I use?", summary: "parked for a human" };
|
|
276
|
+
}
|
|
277
|
+
const run = await app.db
|
|
278
|
+
.table<FeatureRow>("feature_runs", "feature_key")
|
|
279
|
+
.findOne({ feature_key: "owner/repo#7" });
|
|
280
|
+
statusDuringReimpl = run?.status;
|
|
281
|
+
return { status: "opened", pr: "owner/repo#642", summary: "resumed and opened" };
|
|
282
|
+
});
|
|
283
|
+
const featureKey = "owner/repo#7";
|
|
284
|
+
const started = await app.api?.call("startFeature", { body: { issue: featureKey, baseBranch: "epic/e2e" } });
|
|
285
|
+
assert.equal(started?.status, 202, "startFeature accepted the issue");
|
|
286
|
+
await app.settle();
|
|
287
|
+
|
|
288
|
+
const parked = await featureRow(app, featureKey);
|
|
289
|
+
assert.equal(parked.status, "escalated", "the run parks at escalated while awaiting the answer");
|
|
290
|
+
assert.ok(parked.process_key, "the parked run carries its engine process-instance key");
|
|
291
|
+
|
|
292
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: parked.process_key! });
|
|
293
|
+
const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
|
|
294
|
+
assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
|
|
295
|
+
|
|
296
|
+
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
297
|
+
await app.settle();
|
|
298
|
+
|
|
299
|
+
assert.equal(
|
|
300
|
+
statusDuringReimpl,
|
|
301
|
+
"running",
|
|
302
|
+
"the reset task stamped `running` before implement-task re-ran — no stale escalated",
|
|
303
|
+
);
|
|
304
|
+
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
305
|
+
} finally {
|
|
306
|
+
await app.stop();
|
|
307
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
257
311
|
test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
|
|
258
312
|
await withApp(
|
|
259
313
|
{
|
|
@@ -273,7 +327,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
273
327
|
flows.includes("w_gw_answer->record-feature"),
|
|
274
328
|
`abandon routed to record-feature (flows: ${flows.join(", ")})`,
|
|
275
329
|
);
|
|
276
|
-
assert.ok(!flows.includes("w_gw_answer->
|
|
330
|
+
assert.ok(!flows.includes("w_gw_answer->record-feature-implementing"), "the answer loop was NOT taken");
|
|
277
331
|
},
|
|
278
332
|
);
|
|
279
333
|
});
|
|
@@ -322,8 +376,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
322
376
|
|
|
323
377
|
const flows = takenFlows(app);
|
|
324
378
|
assert.ok(
|
|
325
|
-
flows.includes("w_gw_answer->
|
|
326
|
-
|
|
379
|
+
flows.includes("w_gw_answer->record-feature-implementing") &&
|
|
380
|
+
flows.includes("record-feature-implementing->implement-task"),
|
|
381
|
+
`the answer re-dispatched the same implement task through the reset (flows: ${flows.join(", ")})`,
|
|
327
382
|
);
|
|
328
383
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
329
384
|
|
package/nano.app.json
CHANGED
|
@@ -235,6 +235,10 @@
|
|
|
235
235
|
{
|
|
236
236
|
"taskType": "pr.delivery-connector",
|
|
237
237
|
"handler": "workers/delivery-connector/worker.ts"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"taskType": "pr.record-feature-implementing",
|
|
241
|
+
"handler": "workers/record-feature-implementing/worker.ts"
|
|
238
242
|
}
|
|
239
243
|
],
|
|
240
244
|
"externalTaskTypes": [
|
|
@@ -1,25 +1,45 @@
|
|
|
1
|
-
// Tests for the POST /app/api/actions/acknowledge-pr operation `acknowledgePr` (issue #641).
|
|
1
|
+
// Tests for the POST /app/api/actions/acknowledge-pr operation `acknowledgePr` (issue #641, #652).
|
|
2
2
|
// The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL pull request. It stamps `acknowledged_at`,
|
|
3
3
|
// which the `pull_requests_read_model` VIEW (094) derives into `list_bucket` = 'history' and `ack_open`
|
|
4
4
|
// = 0, dropping the finished PR from the Active convergence list into History. Unlike acknowledge-
|
|
5
5
|
// blocked it completes NO user task (a terminal PR is not parked). The PR twin of acknowledge-done /
|
|
6
6
|
// acknowledge-epic.
|
|
7
7
|
//
|
|
8
|
-
// The op's only write is the `acknowledged_at` (+ `updated_at`) stamp
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// The op's only write is the `acknowledged_at` (+ `updated_at`) stamp on the base `pull_requests` row,
|
|
9
|
+
// gated on the TERMINAL tier (`PR_TERMINAL_STATUSES`) — but read off the `pull_requests_read_model`
|
|
10
|
+
// VIEW's FOLDED/effective `status` (issue #652), the SAME source of truth the Dismiss affordance
|
|
11
|
+
// (`ack_open`) derives from, NOT the frozen base column. These tests seed an in-memory read model +
|
|
12
|
+
// base store and assert the 400/404/409/200 gate + the stamp; the bucket derivation itself is proven
|
|
13
|
+
// in app/pullRequestReadModel.test.ts.
|
|
12
14
|
import { test } from "node:test";
|
|
13
15
|
import { assertEquals } from "#test-assert";
|
|
14
16
|
import type { AppApi } from "@nanobpm/urban";
|
|
15
17
|
import { noopLog } from "../test/log.ts";
|
|
16
|
-
import { PR_TERMINAL_STATUSES } from "../app/pullRequestReadModel.ts";
|
|
18
|
+
import { PR_TERMINAL_STATUSES, PULL_REQUEST_READ_MODEL_NAME } from "../app/pullRequestReadModel.ts";
|
|
17
19
|
import handler from "./acknowledgePr.ts";
|
|
18
20
|
|
|
21
|
+
// A read model row (folded/effective `status` the op gates on) and its base `pull_requests` row (the
|
|
22
|
+
// stamp target). `derivedStatus` folds over `status` exactly as `COALESCE(pr.derived_status, pr.status)`
|
|
23
|
+
// does in the 094 VIEW, so a seed can model an out-of-band-terminated PR (base `escalated`, folded
|
|
24
|
+
// `abandoned`) — the #652 drift case.
|
|
25
|
+
type Seed = { prKey: string; status: string; derivedStatus?: string; acknowledgedAt?: string | null };
|
|
26
|
+
|
|
19
27
|
// biome-ignore lint/suspicious/noExplicitAny: test-only fake data layer over dynamic row shapes.
|
|
20
|
-
function memApp(
|
|
28
|
+
function memApp(seeds: Seed[]): { app: AppApi; base: any[] } {
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only store.
|
|
30
|
+
const base: any[] = seeds.map((s) => ({
|
|
31
|
+
pr_key: s.prKey,
|
|
32
|
+
status: s.status,
|
|
33
|
+
acknowledged_at: s.acknowledgedAt ?? null,
|
|
34
|
+
}));
|
|
35
|
+
// The read model VIEW exposes the folded effective status as `status` (COALESCE(derived_status, status)).
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only store.
|
|
37
|
+
const readModel: any[] = seeds.map((s) => ({
|
|
38
|
+
pr_key: s.prKey,
|
|
39
|
+
status: s.derivedStatus ?? s.status,
|
|
40
|
+
}));
|
|
21
41
|
// biome-ignore lint/suspicious/noExplicitAny: test-only store.
|
|
22
|
-
const stores: Record<string, any[]> = { pull_requests:
|
|
42
|
+
const stores: Record<string, any[]> = { pull_requests: base, [PULL_REQUEST_READ_MODEL_NAME]: readModel };
|
|
23
43
|
function tbl(name: string, pk = "id") {
|
|
24
44
|
// biome-ignore lint/suspicious/noExplicitAny: test-only.
|
|
25
45
|
const rows = (stores[name] ??= [] as any[]);
|
|
@@ -40,7 +60,7 @@ function memApp(seed: any[]): { app: AppApi; rows: any[] } {
|
|
|
40
60
|
data: { table: (n: string, pk?: string) => tbl(n, pk) },
|
|
41
61
|
log: noopLog(),
|
|
42
62
|
} as unknown as AppApi;
|
|
43
|
-
return { app,
|
|
63
|
+
return { app, base };
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
async function call(app: AppApi, body: unknown) {
|
|
@@ -49,29 +69,54 @@ async function call(app: AppApi, body: unknown) {
|
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
test("acknowledge-pr: stamps acknowledged_at on a terminal PR and returns 200", async () => {
|
|
52
|
-
const { app,
|
|
72
|
+
const { app, base } = memApp([{ prKey: "o/r#1", status: "merged" }]);
|
|
53
73
|
const res = await call(app, { pr_key: "o/r#1" });
|
|
54
74
|
assertEquals(res.status, 200);
|
|
55
75
|
assertEquals(res.body.ok, true);
|
|
56
|
-
assertEquals(typeof
|
|
57
|
-
assertEquals(typeof
|
|
76
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
77
|
+
assertEquals(typeof base[0].updated_at, "string");
|
|
58
78
|
});
|
|
59
79
|
|
|
60
80
|
test("acknowledge-pr: every terminal status is dismissable", async () => {
|
|
61
81
|
for (const status of PR_TERMINAL_STATUSES) {
|
|
62
|
-
const { app,
|
|
82
|
+
const { app, base } = memApp([{ prKey: "o/r#1", status }]);
|
|
63
83
|
const res = await call(app, { pr_key: "o/r#1" });
|
|
64
84
|
assertEquals(res.status, 200, `status ${status} must be dismissable`);
|
|
65
|
-
assertEquals(typeof
|
|
85
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
66
86
|
}
|
|
67
87
|
});
|
|
68
88
|
|
|
89
|
+
// Issue #652 — the drift case. A PR terminated OUT OF BAND leaves base `status` frozen at its last
|
|
90
|
+
// transient (`escalated`) while the tracking VIEW folds `derived_status` to `abandoned`, so the read
|
|
91
|
+
// model reports the effective `status = abandoned` and lights `ack_open`. Gating on the base column
|
|
92
|
+
// (the old bug) 409'd "pull request is not terminal" on exactly the row the UI shows Dismiss on;
|
|
93
|
+
// gating on the folded read-model status accepts it.
|
|
94
|
+
test("acknowledge-pr: an out-of-band-terminated PR (base escalated, folded abandoned) is dismissable (200)", async () => {
|
|
95
|
+
const { app, base } = memApp([{ prKey: "o/r#1716", status: "escalated", derivedStatus: "abandoned" }]);
|
|
96
|
+
const res = await call(app, { pr_key: "o/r#1716" });
|
|
97
|
+
assertEquals(res.status, 200);
|
|
98
|
+
assertEquals(res.body.ok, true);
|
|
99
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
100
|
+
// The base row's frozen transient is NOT rewritten — only the acknowledgement is stamped; the VIEW
|
|
101
|
+
// folds it to History (list_bucket = 'history', ack_open = 0) off derived_status + acknowledged_at.
|
|
102
|
+
assertEquals(base[0].status, "escalated");
|
|
103
|
+
});
|
|
104
|
+
|
|
69
105
|
test("acknowledge-pr: a live (still-converging) PR is rejected (409) and stays unstamped", async () => {
|
|
70
|
-
const { app,
|
|
106
|
+
const { app, base } = memApp([{ prKey: "o/r#2", status: "converging" }]);
|
|
71
107
|
const res = await call(app, { pr_key: "o/r#2" });
|
|
72
108
|
assertEquals(res.status, 409);
|
|
73
109
|
assertEquals(res.body.ok, false);
|
|
74
|
-
assertEquals(
|
|
110
|
+
assertEquals(base[0].acknowledged_at, null);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// A genuinely live PR whose engine instance is still ACTIVE has `derived_status === status` (a live PR
|
|
114
|
+
// has no terminal fold), so the read model reports the live transient — still not terminal → 409.
|
|
115
|
+
test("acknowledge-pr: a live PR awaiting review (instance active, folded == base) is rejected (409)", async () => {
|
|
116
|
+
const { app, base } = memApp([{ prKey: "o/r#3", status: "waiting_review", derivedStatus: "waiting_review" }]);
|
|
117
|
+
const res = await call(app, { pr_key: "o/r#3" });
|
|
118
|
+
assertEquals(res.status, 409);
|
|
119
|
+
assertEquals(base[0].acknowledged_at, null);
|
|
75
120
|
});
|
|
76
121
|
|
|
77
122
|
test("acknowledge-pr: a missing pr_key → 400", async () => {
|
|
@@ -86,9 +131,9 @@ test("acknowledge-pr: no matching PR → 404", async () => {
|
|
|
86
131
|
});
|
|
87
132
|
|
|
88
133
|
test("acknowledge-pr: idempotent — re-acknowledging a terminal PR re-stamps and returns 200", async () => {
|
|
89
|
-
const { app,
|
|
134
|
+
const { app, base } = memApp([{ prKey: "o/r#5", status: "converged" }]);
|
|
90
135
|
assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
|
|
91
|
-
assertEquals(typeof
|
|
136
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
92
137
|
assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
|
|
93
|
-
assertEquals(typeof
|
|
138
|
+
assertEquals(typeof base[0].acknowledged_at, "string");
|
|
94
139
|
});
|