@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 +12 -0
- package/app/deliveryGraphProposals.test.ts +7 -0
- package/app/resolveApiBase.test.ts +80 -2
- package/app/resolveApiBase.ts +63 -24
- package/docs/agent-guide.md +52 -5
- package/operations/compileDeliveryGraph.test.ts +30 -2
- package/operations/compileDeliveryGraph.ts +6 -2
- package/package.json +1 -1
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
|
|
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
|
+
});
|
package/app/resolveApiBase.ts
CHANGED
|
@@ -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
|
|
21
|
-
|
|
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
|
+
}
|
package/docs/agent-guide.md
CHANGED
|
@@ -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? }
|
|
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" },
|
|
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" },
|
|
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" },
|
|
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" },
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|