@nanobpm/nano-workforce 0.150.2 → 0.150.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.150.4](https://github.com/nanobpm/nano-workforce/compare/v0.150.3...v0.150.4) (2026-08-28)
2
+
3
+ ### Documentation
4
+
5
+ * **agent guide:** document the PT30M wait-gate default and poll bounds in §9 ([#585](https://github.com/nanobpm/nano-workforce/issues/585)) ([3092ae9](https://github.com/nanobpm/nano-workforce/commit/3092ae9c1448c8b540aa62abf58b268e14f3f0ee)), closes [nanobpm/nano-workforce#584](https://github.com/nanobpm/nano-workforce/issues/584)
6
+
7
+ ## [0.150.3](https://github.com/nanobpm/nano-workforce/compare/v0.150.2...v0.150.3) (2026-08-28)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **compileDeliveryGraph:** key reviewUrl to the request origin ([#577](https://github.com/nanobpm/nano-workforce/issues/577)) ([#581](https://github.com/nanobpm/nano-workforce/issues/581)) ([8c04c95](https://github.com/nanobpm/nano-workforce/commit/8c04c95b9d3617b3d71d28e54a7f24cae54c6e2c))
12
+
1
13
  ## [0.150.2](https://github.com/nanobpm/nano-workforce/compare/v0.150.1...v0.150.2) (2026-08-28)
2
14
 
3
15
  ### Bug Fixes
@@ -25,6 +25,7 @@ import {
25
25
  stageProposal,
26
26
  sweepExpiredProposals,
27
27
  } from "./deliveryGraphProposals.ts";
28
+ import { publicBaseUrl } from "./blackboard.ts";
28
29
 
29
30
  const APP_ROOT = resolve(import.meta.dirname, "..");
30
31
 
@@ -87,6 +88,12 @@ test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a
87
88
  assert(!/\/actions\//.test(url), "reviewUrl points at a page, never an API action");
88
89
  });
89
90
 
91
+ test("proposalReviewUrl: with no base falls back to publicBaseUrl() — the text-ingress (no request) path (#577)", () => {
92
+ // The staging text-ingress path has no HTTP request to derive an origin from, so the default base
93
+ // must stay the deployment-wide NANO_WORKFORCE_BASE_URL via publicBaseUrl().
94
+ assertEquals(proposalReviewUrl("abc123"), `${publicBaseUrl()}/app/pages/delivery-graphs#proposal-abc123`);
95
+ });
96
+
90
97
  test("buildProposalRow: stamps status staged, boolean→0/1, and TTL from createdAt", () => {
91
98
  const r = row({ sideEffecting: true, createdAt: "2024-01-01T00:00:00.000Z" });
92
99
  assertEquals(r.status, "staged");
@@ -1,9 +1,10 @@
1
1
  // Tests for app/resolveApiBase.ts — the single canonical control-API base reconstruction shared by
2
- // getAgentInstructions and getAgentSkill. Covers proxy-header handling, scheme restriction,
2
+ // getAgentInstructions and getAgentSkill, plus the human-facing resolvePublicOrigin (#577). Covers
3
+ // proxy-header handling, scheme restriction, host sanitisation, x-forwarded-prefix sanitisation,
3
4
  // host-absent fallback, and mount-suffix stripping for both mount depths.
4
5
  import { test } from "node:test";
5
6
  import { assertEquals } from "#test-assert";
6
- import { resolveApiBase } from "./resolveApiBase.ts";
7
+ import { resolveApiBase, resolvePublicOrigin } from "./resolveApiBase.ts";
7
8
 
8
9
  function req(headers: Record<string, string>, path: string) {
9
10
  return { path, headers: new Headers(headers) };
@@ -42,6 +43,7 @@ test("strips multiple trailing slashes after the mount suffix", () => {
42
43
  assertEquals(resolveApiBase(req({ host: "h" }, "/app/api/agent/skill///"), "agent/skill"), "http://h/app/api");
43
44
  });
44
45
 
46
+ // ── resolveApiBase: x-forwarded-prefix sanitisation (#580) via the shared sanitiseForwardedPrefix ──
45
47
  test("prepends a validated x-forwarded-prefix to the reconstructed base", () => {
46
48
  const r = req(
47
49
  { host: "nano.ngrok-free.dev", "x-forwarded-prefix": "/console/app-view/Workforce" },
@@ -97,3 +99,79 @@ test("prefix composes with x-forwarded-proto and x-forwarded-host", () => {
97
99
  );
98
100
  assertEquals(resolveApiBase(r, "agent/skill"), "https://nano.ngrok-free.dev/console/app-view/Workforce/app/api");
99
101
  });
102
+
103
+ // ── resolvePublicOrigin: the human-facing ORIGIN (+ proxy prefix), no /app/api suffix (#577) ──
104
+ // Shares sanitiseForwardedPrefix with resolveApiBase, so the prefix policy (absolute-only, reject
105
+ // scheme/authority/traversal entirely, percent-aware) is identical on both surfaces — no drift.
106
+ test("resolvePublicOrigin: bare origin from the host header", () => {
107
+ assertEquals(resolvePublicOrigin(req({ host: "wf.example.com" }, "/app/api/actions/compile-delivery-graph")), "http://wf.example.com");
108
+ });
109
+
110
+ test("resolvePublicOrigin: honours x-forwarded-proto and x-forwarded-host", () => {
111
+ const r = req({ host: "internal", "x-forwarded-host": "example.test", "x-forwarded-proto": "https" }, "/app/api/actions/compile-delivery-graph");
112
+ assertEquals(resolvePublicOrigin(r), "https://example.test");
113
+ });
114
+
115
+ test("resolvePublicOrigin: restricts x-forwarded-proto to http/https", () => {
116
+ const r = req({ host: "wf.example.com", "x-forwarded-proto": "javascript" }, "/app/api/actions/compile-delivery-graph");
117
+ assertEquals(resolvePublicOrigin(r), "http://wf.example.com");
118
+ });
119
+
120
+ test("resolvePublicOrigin: appends the reverse-proxy x-forwarded-prefix", () => {
121
+ const r = req(
122
+ { "x-forwarded-host": "nano.ngrok-free.dev", "x-forwarded-proto": "https", "x-forwarded-prefix": "/console/app-view/Workforce" },
123
+ "/app/api/actions/compile-delivery-graph",
124
+ );
125
+ assertEquals(resolvePublicOrigin(r), "https://nano.ngrok-free.dev/console/app-view/Workforce");
126
+ });
127
+
128
+ test("resolvePublicOrigin: normalises a trailing slash on x-forwarded-prefix", () => {
129
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/app-view/Workforce/" }, "/app/api/actions/compile-delivery-graph");
130
+ assertEquals(resolvePublicOrigin(r), "http://h/console/app-view/Workforce");
131
+ });
132
+
133
+ test("resolvePublicOrigin: treats a slash-only x-forwarded-prefix as empty (no double slash)", () => {
134
+ const r = req({ host: "h", "x-forwarded-prefix": "///" }, "/app/api/actions/compile-delivery-graph");
135
+ assertEquals(resolvePublicOrigin(r), "http://h");
136
+ });
137
+
138
+ test("resolvePublicOrigin: rejects a path-traversal x-forwarded-prefix entirely", () => {
139
+ const r = req({ host: "h", "x-forwarded-prefix": "/console/../../etc" }, "/app/api/actions/compile-delivery-graph");
140
+ assertEquals(resolvePublicOrigin(r), "http://h");
141
+ });
142
+
143
+ test("resolvePublicOrigin: rejects a scheme/authority x-forwarded-prefix", () => {
144
+ const r = req({ host: "h", "x-forwarded-prefix": "https://evil.example/hijack" }, "/app/api/actions/compile-delivery-graph");
145
+ assertEquals(resolvePublicOrigin(r), "http://h");
146
+ });
147
+
148
+ test("resolvePublicOrigin: rejects a relative (non-absolute) x-forwarded-prefix", () => {
149
+ const r = req({ host: "h", "x-forwarded-prefix": "@evil.example" }, "/app/api/actions/compile-delivery-graph");
150
+ assertEquals(resolvePublicOrigin(r), "http://h");
151
+ });
152
+
153
+ test("resolvePublicOrigin: falls back to a localhost origin when the Host header is absent", () => {
154
+ assertEquals(resolvePublicOrigin(req({}, "/app/api/actions/compile-delivery-graph")), "http://localhost:3000");
155
+ });
156
+
157
+ // ── host sanitisation: the untrusted x-forwarded-host/host authority is reflected into the URL ──
158
+ test("rejects a userinfo-injecting host (falls back to localhost)", () => {
159
+ const r = req({ "x-forwarded-host": "evil.com@real.example" }, "/app/api/actions/compile-delivery-graph");
160
+ assertEquals(resolvePublicOrigin(r), "http://localhost:3000");
161
+ assertEquals(resolveApiBase(req({ "x-forwarded-host": "evil.com@real.example" }, "/app/api/agent"), "agent"), "http://localhost:3000/app/api");
162
+ });
163
+
164
+ test("rejects a path-injecting host (falls back to localhost)", () => {
165
+ const r = req({ "x-forwarded-host": "real.example/extra-path" }, "/app/api/actions/compile-delivery-graph");
166
+ assertEquals(resolvePublicOrigin(r), "http://localhost:3000");
167
+ });
168
+
169
+ test("accepts a host:port authority", () => {
170
+ const r = req({ "x-forwarded-host": "wf.example.com:8443", "x-forwarded-proto": "https" }, "/app/api/actions/compile-delivery-graph");
171
+ assertEquals(resolvePublicOrigin(r), "https://wf.example.com:8443");
172
+ });
173
+
174
+ test("accepts a bracketed IPv6 host authority", () => {
175
+ const r = req({ "x-forwarded-host": "[2001:db8::1]:3000" }, "/app/api/actions/compile-delivery-graph");
176
+ assertEquals(resolvePublicOrigin(r), "http://[2001:db8::1]:3000");
177
+ });
@@ -17,33 +17,72 @@
17
17
  * "/app/api" when the path is nothing but the suffix.
18
18
  */
19
19
  export function resolveApiBase(req: { path: string; headers: Headers }, mountSuffix: string): string {
20
- const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
21
- // x-forwarded-proto is user-controlled behind some proxies; only trust http/https.
22
- const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
23
- const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
24
- // The external path prefix stripped by a reverse proxy (e.g. the console app-view proxy mounts us
25
- // under "/console/app-view/{project}"). X-Forwarded-Prefix is the de-facto standard header for it.
26
- // It is untrusted, proxy-supplied input that ends up in a URL handed to an agent, so validate it as
27
- // strictly as x-forwarded-proto above: accept only an absolute path of URL-safe path characters —
28
- // rejecting anything with a scheme, an authority ("//host"), or a "."/".." traversal segment — then
29
- // drop trailing slashes so it composes cleanly with the base path. Anything else falls back to an
30
- // empty prefix, i.e. today's behaviour.
31
- //
32
- // Percent-encoding can smuggle those forms past a literal check: "%2e%2e" decodes to "..", and
33
- // "%2f%2f" decodes to an authority-introducing "//". So normalise the common encoded spellings of
34
- // "." and "/" (case-insensitively) before rejecting dot-segments and "//"; the still-encoded raw
35
- // value is what we reflect once it validates.
36
- const rawPrefix = (req.headers.get("x-forwarded-prefix") ?? "").split(",")[0].trim();
37
- const decodedPrefix = rawPrefix.replace(/%2e/gi, ".").replace(/%2f/gi, "/");
38
- const prefix =
39
- /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/.test(rawPrefix) &&
40
- !decodedPrefix.includes("//") &&
41
- !/(^|\/)\.\.?(\/|$)/.test(decodedPrefix)
42
- ? rawPrefix.replace(/\/+$/, "")
43
- : "";
20
+ const { proto, host } = requestProtoHost(req);
21
+ const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix"));
44
22
  // The op is mounted at "<base>/<mountSuffix>"; strip the trailing segments to recover the base path.
45
23
  const suffix = mountSuffix.replace(/^\/+/, "").replace(/\/+$/, "");
46
24
  const stripRe = new RegExp(`/${suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/*$`);
47
25
  const basePath = req.path.replace(stripRe, "") || "/app/api";
48
26
  return host ? `${proto}://${host}${prefix}${basePath}` : `http://localhost:3000${prefix}${basePath}`;
49
27
  }
28
+
29
+ /** The public ORIGIN (+ proxy prefix) this request arrived on — the base for a navigational link
30
+ * handed back to the caller (e.g. a cockpit deep-link), WITHOUT the `/app/api` mount suffix that
31
+ * {@link resolveApiBase} keeps. Where `resolveApiBase` reconstructs the control-API base an *agent*
32
+ * calls back on, this reconstructs the human-facing origin: proto + host (same proxy-header trust as
33
+ * `resolveApiBase`) plus any reverse-proxy path prefix advertised via `x-forwarded-prefix`, so a
34
+ * link built as `${resolvePublicOrigin(req)}/app/pages/…` opens on the exact origin the operator is
35
+ * driving this app from (e.g. a tunnel), not a static deployment-wide base. Falls back to a
36
+ * localhost origin when the Host header is absent (a raw unit-test request). */
37
+ export function resolvePublicOrigin(req: { path: string; headers: Headers }): string {
38
+ const { proto, host } = requestProtoHost(req);
39
+ const prefix = sanitiseForwardedPrefix(req.headers.get("x-forwarded-prefix"));
40
+ return host ? `${proto}://${host}${prefix}` : `http://localhost:3000${prefix}`;
41
+ }
42
+
43
+ /** Sanitise the untrusted, proxy/user-controlled `x-forwarded-prefix` into a safe leading-slash,
44
+ * no-trailing-slash path segment (or ""). The ONE canonical prefix sanitiser shared by
45
+ * {@link resolveApiBase} and {@link resolvePublicOrigin} (AGENTS.md "derivation over duplication") —
46
+ * the prefix is the reverse-proxy path the public URL was mounted under (e.g.
47
+ * "/console/app-view/Workforce") and is reflected into a caller-facing URL, so it must not smuggle a
48
+ * scheme, an authority ("//host"), or a "."/".." traversal segment into the URL. Accept only an
49
+ * absolute path of URL-safe path characters, then drop trailing slashes so it composes cleanly with
50
+ * the base path; anything else falls back to an empty prefix. Percent-encoding can smuggle those
51
+ * forms past a literal check ("%2e%2e" decodes to "..", "%2f%2f" to an authority-introducing "//"),
52
+ * so normalise the common encoded spellings of "." and "/" (case-insensitively) before rejecting
53
+ * dot-segments and "//"; the still-encoded raw value is what we reflect once it validates. Because
54
+ * the return is always either "" or a leading-"/" path, it can never alter the `${proto}://${host}`
55
+ * authority. */
56
+ function sanitiseForwardedPrefix(raw: string | null): string {
57
+ const rawPrefix = (raw ?? "").split(",")[0].trim();
58
+ const decodedPrefix = rawPrefix.replace(/%2e/gi, ".").replace(/%2f/gi, "/");
59
+ return /^\/(?!\/)[A-Za-z0-9._~\-/%]*$/.test(rawPrefix) &&
60
+ !decodedPrefix.includes("//") &&
61
+ !/(^|\/)\.\.?(\/|$)/.test(decodedPrefix)
62
+ ? rawPrefix.replace(/\/+$/, "")
63
+ : "";
64
+ }
65
+
66
+ /** The trusted (proto, host) pair for a request — the ONE place proxy-header handling lives so
67
+ * `resolveApiBase` and `resolvePublicOrigin` can't drift (AGENTS.md "derivation over duplication").
68
+ * Only `http`/`https` are trusted from the user-controlled `x-forwarded-proto`; the host prefers
69
+ * `x-forwarded-host` over `host`. `host` is "" when neither header is present or the advertised host
70
+ * is not a valid authority (see {@link sanitiseHost}). */
71
+ function requestProtoHost(req: { headers: Headers }): { proto: string; host: string } {
72
+ const rawProto = (req.headers.get("x-forwarded-proto") ?? "http").split(",")[0].trim().toLowerCase();
73
+ const proto = rawProto === "http" || rawProto === "https" ? rawProto : "http";
74
+ const rawHost = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(",")[0].trim();
75
+ return { proto, host: sanitiseHost(rawHost) };
76
+ }
77
+
78
+ /** Sanitise the untrusted, proxy/user-controlled host (`x-forwarded-host`/`host`) into a bare
79
+ * authority — a registered name or IPv4 with an optional `:port`, or a bracketed IPv6 literal with
80
+ * an optional `:port` — or "" when it carries anything else. The host is reflected verbatim into the
81
+ * `${proto}://${host}` authority of a caller-facing URL, so a hostile value like
82
+ * `evil.com@real.example` (userinfo injection) or `real.example/extra-path` (path injection) must be
83
+ * rejected outright rather than smuggled through. */
84
+ function sanitiseHost(host: string): string {
85
+ if (!host) return "";
86
+ const valid = /^(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::\d+)?$/.test(host);
87
+ return valid ? host : "";
88
+ }
@@ -180,6 +180,12 @@ curl -sS __BASE__/../../tasks/api/tasks \
180
180
  The inbox UI is also served at `__BASE__/../../tasks` for a human to browse, filter, and
181
181
  answer (assignee/candidate-group and age surface on each task once assignment lands).
182
182
 
183
+ > **`wait`-gate escalations are different — completing one does NOT re-arm the gate.** The
184
+ > escalation kinds above resume a loop with your answer. A **delivery-graph `wait` node**
185
+ > (§9) that elapses its bound also parks a task, but completing *that* one releases the token
186
+ > **as not-ready** and the graph proceeds **past the gate** — the downstream side-effecting
187
+ > node then runs against the unmet dependency. See §9.2 before clearing one.
188
+
183
189
  **Answer a task** by completing it with the typed variables its form expects — the
184
190
  completion resumes the parked process:
185
191
 
@@ -438,7 +444,20 @@ layer schedules, it does not re-implement execution):
438
444
  | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Two **real targets** ship today — **`converge`** and **`converge-merge`** (§9.4); other targets are a forward-declared stub. | yes |
439
445
 
440
446
  A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
441
- intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
447
+ intake uses): `{ kind, target, onTimeout?, match?, poll? }`, where `poll` is
448
+ `{ everyMs?, timeoutMs?, backoff? }` — `everyMs` is the re-probe cadence, `timeoutMs` the
449
+ total bounded budget, and `backoff ∈ fixed|exponential`.
450
+
451
+ > **The bound is invisible unless you set it.** When `poll` (or `poll.timeoutMs`) is
452
+ > **omitted**, the gate inherits the built-in default budget of **`PT30M` (30 minutes),
453
+ > re-probing every 15s** (`DEFAULT_READINESS_TIMEOUT` / `DEFAULT_EVERY_MS`,
454
+ > `app/readiness.ts`). That default is right for *"is the package published yet"* and badly
455
+ > wrong for `wait[pr, merged]` / `wait[epic]`, which routinely wait **hours or days** — an
456
+ > unpopulated `poll` on such a gate escalates after 30 minutes for no visible reason. Neither
457
+ > `compile` nor `preview` surfaces the effective bound, so **set `poll.timeoutMs` explicitly**
458
+ > on any gate that waits on a merge or an epic (see §9.4 / §9.5).
459
+
460
+ The **`pr` kind** watches an
442
461
  in-flight PR — `target: "owner/repo#123"`, `match.prState ∈ ready|merged|mergeable|checks-green`
443
462
  (default `merged`) — and on a merged match binds `mergedSha` as an output fact. The **`epic`
444
463
  kind** (issue #568) gates on an **nwf plan-fanout epic reaching "fully merged"** — `target:
@@ -526,6 +545,18 @@ Graphs** grid (e.g. *"parked on human node: manual OTP publish"*). A `human` nod
526
545
  **Tasks** inbox and is answered exactly as an escalation is (§3) — its completion emits any
527
546
  declared facts, which downstream edges bind.
528
547
 
548
+ > **Completing a `wait` escalation proceeds *as not-ready* — it does NOT re-arm the gate.**
549
+ > A `wait` node with `onTimeout: "escalate"` (the default) that elapses its bound parks a
550
+ > human-completable escalation task on the Tasks inbox. **Completing that task does not retry
551
+ > the probe or wait for readiness** — it releases the token **as not-ready** and the graph
552
+ > proceeds **past the gate**, so the downstream side-effecting node then runs *against the
553
+ > unmet dependency* (`waitBodyLines`, `app/deliveryGraphCompiler.ts`). An operator clearing
554
+ > what looks like a stuck task therefore *launches the very work the gate was holding back*.
555
+ > If the dependency genuinely is not ready, do **not** complete the escalation to "unstick"
556
+ > it — extend the gate's `poll.timeoutMs` and re-dispatch, or abandon the run. (Same for
557
+ > `onTimeout: "continue"`, which proceeds past the gate as not-ready with **no** human stop
558
+ > at all.)
559
+
529
560
  > **Why the split?** Making the compile door the end of the agent surface closes a
530
561
  > self-approval hole: the old flow handed the same caller a content-addressed approval token
531
562
  > to re-submit with, so any holder of the API credential approved its own graph. Removing the
@@ -544,7 +575,8 @@ publish and records the version → open+merge PR #303 (repo 3) consuming that v
544
575
  "name": "cross-repo release: merge #101 → un-draft+merge #202 → manual OTP publish → consume in #303",
545
576
  "nodes": [
546
577
  { "id": "merge-a", "kind": "wait",
547
- "wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" }, "onTimeout": "escalate" } },
578
+ "wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" },
579
+ "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } },
548
580
  { "id": "undraft-merge-b", "kind": "agent",
549
581
  "agent": { "jobType": "senior:merge", "prompt": "Take draft PR acme/repo-2#202 out of draft and merge it once its required checks are green." } },
550
582
  { "id": "manual-publish", "kind": "human",
@@ -553,7 +585,8 @@ publish and records the version → open+merge PR #303 (repo 3) consuming that v
553
585
  { "id": "open-pr-c", "kind": "agent",
554
586
  "agent": { "jobType": "senior:feature", "prompt": "Bump @acme/widget to the published version in acme/repo-3 and open PR #303." } },
555
587
  { "id": "merge-c", "kind": "wait",
556
- "wait": { "kind": "pr", "target": "acme/repo-3#303", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
588
+ "wait": { "kind": "pr", "target": "acme/repo-3#303", "match": { "prState": "merged" },
589
+ "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } }
557
590
  ],
558
591
  "edges": [
559
592
  { "from": "merge-a", "to": "undraft-merge-b" },
@@ -622,7 +655,8 @@ author never knows the PR number at compose time, so reference it by fact:
622
655
  { "id": "land", "kind": "connector",
623
656
  "connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
624
657
  { "id": "merged", "kind": "wait",
625
- "wait": { "kind": "pr", "target": "open.pr", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
658
+ "wait": { "kind": "pr", "target": "open.pr", "match": { "prState": "merged" },
659
+ "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } }
626
660
  ],
627
661
  "edges": [
628
662
  { "from": "open.pr", "to": "land" },
@@ -631,6 +665,11 @@ author never knows the PR number at compose time, so reference it by fact:
631
665
  }
632
666
  ```
633
667
 
668
+ The `merged` gate carries an explicit **`poll`** (re-probe every 5 minutes, budget 3 days:
669
+ `timeoutMs: 259200000`) because a `wait[pr, merged]` waits on a human-paced merge — **omitting
670
+ `poll` inherits the 30-minute default** (§9.1) and escalates mid-review. Set `poll.timeoutMs`
671
+ to a realistic budget on any merge/epic gate.
672
+
634
673
  The `pr` fact is threaded along the **fact-qualified edges** (`open.pr → land`, `open.pr → merged`) —
635
674
  those edges are what carry the observed PR into each consumer, so they are **required** when you
636
675
  reference `open.pr` (the validator rejects a reference with no threading edge, `unbound-pr`). Three
@@ -661,7 +700,9 @@ babysitting a `confirm` gate.
661
700
  "nodes": [
662
701
  { "id": "gate-epic", "kind": "wait",
663
702
  "wait": { "kind": "epic", "target": "nanobpm/nano-ide#488",
664
- "match": { "epicState": "merged" }, "onTimeout": "escalate" },
703
+ "match": { "epicState": "merged" },
704
+ "poll": { "everyMs": 300000, "timeoutMs": 259200000 },
705
+ "onTimeout": "escalate" },
665
706
  "emits": [ { "name": "prCount", "type": "number" } ] },
666
707
  { "id": "start-b", "kind": "agent",
667
708
  "agent": { "jobType": "senior:feature", "prompt": "Implement nanobpm/nano-workforce#567 and open a PR." } }
@@ -680,5 +721,11 @@ Semantics:
680
721
  - **A failed/abandoned/mixed epic never reports merged**, so it never falsely releases the
681
722
  gate; the **bounded** wait elapses and routes via **`onTimeout`** (`escalate`/`continue`) —
682
723
  it does **not** hang.
724
+ - **Set `poll.timeoutMs` to a realistic budget.** An epic reaching "fully merged" is a
725
+ multi-day, human-paced event, so the example gives it `poll: { everyMs: 300000, timeoutMs:
726
+ 259200000 }` (re-probe every 5 minutes, budget 3 days). **Omitting `poll` inherits the
727
+ 30-minute default** (§9.1) — the gate would escalate long before the epic lands, and (per
728
+ §9.2) completing that escalation would release `start-b` **as not-ready**, launching feature
729
+ B before its dependency merged. Size `timeoutMs` to how long the epic realistically takes.
683
730
  - On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
684
731
  output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
@@ -30,8 +30,9 @@ async function withApp(fn: (app: AppApi, data: DataLayer) => Promise<void>): Pro
30
30
  }
31
31
  }
32
32
 
33
- async function call(app: AppApi, body: unknown) {
34
- return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
33
+ async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}) {
34
+ const req = { path: "/app/api/actions/compile-delivery-graph", headers: new Headers(headers) };
35
+ return (await handler({ req: req as any, params: {}, query: {}, body } as any, app)) as any;
35
36
  }
36
37
 
37
38
  const GOOD = {
@@ -79,6 +80,33 @@ test("compile-delivery-graph: the response exposes NO dispatch handle — no run
79
80
  });
80
81
  });
81
82
 
83
+ // ── #577: reviewUrl is a caller-facing link → keyed to the request origin, not the static base ──
84
+ test("compile-delivery-graph: reviewUrl is on the request's forwarded origin, not NANO_WORKFORCE_BASE_URL", async () => {
85
+ await withApp(async (app) => {
86
+ const res = await call(app, GOOD, { "x-forwarded-proto": "https", "x-forwarded-host": "example.test" });
87
+ assertEquals(res.status, 200);
88
+ assertEquals(
89
+ res.body.reviewUrl,
90
+ `https://example.test/app/pages/delivery-graphs#proposal-${res.body.digest}`,
91
+ );
92
+ });
93
+ });
94
+
95
+ test("compile-delivery-graph: reviewUrl honours the reverse-proxy x-forwarded-prefix", async () => {
96
+ await withApp(async (app) => {
97
+ const res = await call(app, GOOD, {
98
+ "x-forwarded-proto": "https",
99
+ "x-forwarded-host": "nano.ngrok-free.dev",
100
+ "x-forwarded-prefix": "/console/app-view/Workforce",
101
+ });
102
+ assertEquals(res.status, 200);
103
+ assertEquals(
104
+ res.body.reviewUrl,
105
+ `https://nano.ngrok-free.dev/console/app-view/Workforce/app/pages/delivery-graphs#proposal-${res.body.digest}`,
106
+ );
107
+ });
108
+ });
109
+
82
110
  test("compile-delivery-graph: re-compiling the same graph is idempotent — one staged proposal, TTL anchored to the first stage", async () => {
83
111
  await withApp(async (app, data) => {
84
112
  const first = await call(app, GOOD);
@@ -21,12 +21,13 @@ import {
21
21
  stageProposal,
22
22
  } from "../app/deliveryGraphProposals.ts";
23
23
  import { deliveryGraphDigest } from "../app/deliveryRunner.ts";
24
+ import { resolvePublicOrigin } from "../app/resolveApiBase.ts";
24
25
  import { defineOperation } from "../nano-generated/operations.ts";
25
26
 
26
27
  const STAGED_MESSAGE =
27
28
  "The graph compiled and is staged for operator review. Ask the operator to preview and approve — or request modifications — in the cockpit. Dispatch is an operator action; there is no start endpoint.";
28
29
 
29
- export default defineOperation("compileDeliveryGraph", async ({ body }, app) => {
30
+ export default defineOperation("compileDeliveryGraph", async ({ body, req }, app) => {
30
31
  // The runtime validates the body's SHAPE against `DeliveryGraph` before we run; the compiler adds
31
32
  // the SEMANTIC checks (acyclicity, edge integrity, fact resolution). A directly-invoked delegate
32
33
  // could still pass `undefined` — the compiler reads its input as `unknown` and maps that to a clean
@@ -75,7 +76,10 @@ export default defineOperation("compileDeliveryGraph", async ({ body }, app) =>
75
76
  message: STAGED_MESSAGE,
76
77
  digest,
77
78
  preview,
78
- reviewUrl: proposalReviewUrl(digest),
79
+ // Navigational, human-facing link → keyed to the ORIGIN this request arrived on (tunnel,
80
+ // proxy prefix, …), not the static deployment-wide NANO_WORKFORCE_BASE_URL, so the operator
81
+ // driving this instance can actually open it (#577).
82
+ reviewUrl: proposalReviewUrl(digest, resolvePublicOrigin(req)),
79
83
  },
80
84
  };
81
85
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.150.2",
3
+ "version": "0.150.4",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",