Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,61 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [8.2.0] - 2026-05-23 — `createHITLRequest` for explicit HITL row creation

Enables agent-framework callers (Google ADK, n8n, OpenAI Agents SDK) to
implement the full 4-step HITL approval flow against AxonFlow:

1. Gate evaluates `require_approval` (via `pre_check` / `checkToolInput`)
2. Caller invokes `axonflow.createHITLRequest(...)` to enqueue the row
3. Caller polls `axonflow.getHITLRequest(approvalId)` until terminal state
4. Caller resumes the agent or denies the call based on the decision

Prior to this release the SDK exposed `getHITLRequest` /
`approveHITLRequest` / `rejectHITLRequest` (the read + review
surface) but had no method to **create** a row. The platform's
`POST /api/v1/hitl/queue` endpoint has existed since v6.x; only the
SDK surface was missing.

### Added

- **`AxonFlow#createHITLRequest(HITLCreateInput) -> HITLApprovalRequest`**
+ `AxonFlow#createHITLRequestAsync(HITLCreateInput) -> CompletableFuture<HITLApprovalRequest>`.
Required fields: `clientId`, `originalQuery`, `requestType`.
Optional fields cover policy attribution, severity, compliance
framework, an expiry override, and the new `notifyUrl` callback.
Server-side `X-Org-ID` / `X-Tenant-ID` headers are derived by the
platform's auth middleware from the SDK client's configured
credentials — callers do not pass them through this method.
- **`HITLCreateInput` POJO + Builder** in
`com.getaxonflow.sdk.types.hitl.HITLTypes` mirroring
`platform/agent/hitl/handler.go:86 CreateRequestInput`.
- **`notifyUrl` field on `HITLCreateInput` and `HITLApprovalRequest`.**
Opt-in webhook URL fired after the request reaches a terminal state
(approved / rejected / expired / overridden). Pairs with the
HMAC-SHA256 `X-AxonFlow-Signature` header on the receiver side.
Scheme allowlist (`https://`, plus `http://` for self-hosted
local-dev) is enforced server-side; bad schemes surface as an
exception from the SDK carrying the platform's `HTTP 400`. Companion
platform work in getaxonflow/axonflow-enterprise#2419.
- 8 JUnit cases covering: full-fields create, minimal required-fields
create, bad-`notifyUrl`-scheme 400 propagation, 401 propagation,
connect failure propagation (via WireMock `CONNECTION_RESET_BY_PEER`
fault), and the three required-field validation guards
(`client_id` / `original_query` / `request_type`).

### Compatibility

No breaking changes. New types are additive in
`com.getaxonflow.sdk.types.hitl.HITLTypes`. The existing
`getHITLRequest` / `approveHITLRequest` / `rejectHITLRequest` /
`listHITLQueue` / `getHITLStats` methods are unchanged. `notifyUrl`
on the response POJO is optional and absent in payloads from platforms
that don't yet implement the field; older code parses the new shape
cleanly via `@JsonIgnoreProperties(ignoreUnknown = true)`.

Cross-SDK parity sweep: getaxonflow/axonflow-enterprise#2421.

## [8.1.0] - 2026-05-22 — `X-Client-ID` header on every outbound request + `org_id` in telemetry heartbeat + retry-config doc honesty

Companion release to the v9 identity cleanup on the platform. Every
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.1.0</version>
<version>8.2.0</version>
<packaging>jar</packaging>

<name>AxonFlow Java SDK</name>
Expand Down
182 changes: 182 additions & 0 deletions runtime-e2e/create_hitl_request/CreateHITLRequestTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* runtime-e2e/create_hitl_request/CreateHITLRequestTest.java
*
* Real-wire test of the SDK's createHITLRequest method
* (getaxonflow/axonflow-enterprise#2421). Spins up a tiny in-process
* HttpServer that mimics the platform handler at
* platform/agent/hitl/handler.go:177, drives axonflow.createHITLRequest
* against it through the real OkHttp transport, then asserts the
* captured request body carries every required field plus the new
* notify_url surface added in
* getaxonflow/axonflow-enterprise#2419.
*
* No WireMock, no JUnit, no test doubles — real HttpServer +
* OkHttpClient on both sides. Satisfies the runtime-e2e/ DoD gate
* that the WireMock-based HITLTest unit suite under src/test/java/
* does not.
*
* Run:
* ./mvnw -q package -DskipTests
* java -cp "target/axonflow-sdk-8.2.0.jar:$(./mvnw -q dependency:build-classpath -Dmdep.outputFile=/dev/stderr 2>&1 | tail -1)" \
* runtime-e2e/create_hitl_request/CreateHITLRequestTest.java
*
* Sister coverage runs in CI via HITLTest's WireMock-driven 8-case
* createHITLRequest scenario. This proof additionally exercises the
* real JDK HttpServer rather than WireMock's Jetty harness.
*/
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.getaxonflow.sdk.AxonFlow;
import com.getaxonflow.sdk.AxonFlowConfig;
import com.getaxonflow.sdk.types.hitl.HITLTypes.HITLApprovalRequest;
import com.getaxonflow.sdk.types.hitl.HITLTypes.HITLCreateInput;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;

public class CreateHITLRequestTest {

private static final String NOTIFY_URL = "https://workflows.example.com/hooks/runtime-e2e";

public static void main(String[] args) throws Exception {
AtomicReference<String> captured = new AtomicReference<>("");

HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext(
"/api/v1/hitl/queue",
exchange -> {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
exchange.getRequestBody().transferTo(bos);
captured.set(bos.toString(StandardCharsets.UTF_8));
}
if (!"POST".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1);
exchange.close();
return;
}
ObjectMapper mapper = new ObjectMapper();
JsonNode in = mapper.readTree(captured.get());
String resp =
"{\"success\":true,\"data\":{"
+ "\"request_id\":\"hitl-req-runtime-e2e-001\","
+ "\"org_id\":\"org-runtime-e2e\","
+ "\"tenant_id\":\"tenant-runtime-e2e\","
+ "\"client_id\":"
+ mapper.writeValueAsString(in.path("client_id").asText())
+ ","
+ "\"user_id\":"
+ mapper.writeValueAsString(in.path("user_id").asText())
+ ","
+ "\"original_query\":"
+ mapper.writeValueAsString(in.path("original_query").asText())
+ ","
+ "\"request_type\":"
+ mapper.writeValueAsString(in.path("request_type").asText())
+ ","
+ "\"triggered_policy_id\":"
+ mapper.writeValueAsString(in.path("triggered_policy_id").asText())
+ ","
+ "\"triggered_policy_name\":"
+ mapper.writeValueAsString(in.path("triggered_policy_name").asText())
+ ","
+ "\"trigger_reason\":"
+ mapper.writeValueAsString(in.path("trigger_reason").asText())
+ ","
+ "\"severity\":"
+ mapper.writeValueAsString(in.path("severity").asText())
+ ","
+ "\"notify_url\":"
+ mapper.writeValueAsString(in.path("notify_url").asText())
+ ","
+ "\"status\":\"pending\","
+ "\"expires_at\":\"2026-05-23T11:00:00Z\","
+ "\"created_at\":\"2026-05-23T10:00:00Z\","
+ "\"updated_at\":\"2026-05-23T10:00:00Z\""
+ "}}";
byte[] bytes = resp.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(201, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
int port = server.getAddress().getPort();
String endpoint = "http://127.0.0.1:" + port;

int exitCode = 0;
try {
AxonFlow axonflow =
AxonFlow.create(AxonFlowConfig.builder().endpoint(endpoint).build());

HITLCreateInput input =
HITLCreateInput.builder()
.clientId("runtime-e2e-client")
.userId("runtime-e2e-user")
.originalQuery("disburse $50000 to cust-runtime-e2e")
.requestType("adk-tool")
.triggeredPolicyId("loan-amount-cap")
.triggeredPolicyName("Loan amount cap")
.triggerReason("Disbursement above $10k requires manager approval")
.severity("high")
.notifyUrl(NOTIFY_URL)
.build();

HITLApprovalRequest req = axonflow.createHITLRequest(input);

String body = captured.get();
if (body == null || body.isEmpty()) {
System.err.println("FAIL: server captured no body");
exitCode = 1;
} else {
ObjectMapper mapper = new ObjectMapper();
JsonNode wire = mapper.readTree(body);
String[][] expected = {
{"client_id", "runtime-e2e-client"},
{"user_id", "runtime-e2e-user"},
{"original_query", "disburse $50000 to cust-runtime-e2e"},
{"request_type", "adk-tool"},
{"triggered_policy_id", "loan-amount-cap"},
{"triggered_policy_name", "Loan amount cap"},
{"trigger_reason", "Disbursement above $10k requires manager approval"},
{"severity", "high"},
{"notify_url", NOTIFY_URL},
};
for (String[] kv : expected) {
String got = wire.path(kv[0]).asText();
if (!kv[1].equals(got)) {
System.err.printf(
"FAIL: wire body field %s = %s, want %s%nFull body: %s%n",
kv[0], got, kv[1], body);
exitCode = 1;
}
}
if (!"hitl-req-runtime-e2e-001".equals(req.getRequestId())) {
System.err.printf("FAIL: parsed request_id = %s%n", req.getRequestId());
exitCode = 1;
}
if (!NOTIFY_URL.equals(req.getNotifyUrl())) {
System.err.printf(
"FAIL: parsed notify_url = %s, want %s%n", req.getNotifyUrl(), NOTIFY_URL);
exitCode = 1;
}
}

if (exitCode == 0) {
System.out.println(
"PASS: createHITLRequest wire payload + response parsing round-trip OK");
System.out.println("Wire body: " + body);
System.out.printf(
"Parsed requestId=%s notifyUrl=%s%n", req.getRequestId(), req.getNotifyUrl());
}
} catch (Exception e) {
System.err.println("FAIL: unexpected exception: " + e);
e.printStackTrace();
exitCode = 1;
} finally {
server.stop(0);
}
System.exit(exitCode);
}
}
44 changes: 44 additions & 0 deletions runtime-e2e/create_hitl_request/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# `createHITLRequest` — runtime-e2e

Real-stack assertion for the cross-SDK
[`createHITLRequest`](https://github.com/getaxonflow/axonflow-enterprise/issues/2421)
surface added in Java SDK v8.2.0. Sister proof to the equivalent Python
/ TypeScript / Go runtime-e2e tests shipping in the same parity sweep.

## What this proves

Drives `axonflow.createHITLRequest(...)` through the real `OkHttp`
transport against a JDK `com.sun.net.httpserver.HttpServer` listener
that mimics the platform handler at
`platform/agent/hitl/handler.go:177`. Captures the raw HTTP body,
decodes it, and asserts every required field from
`com.getaxonflow.sdk.types.hitl.HITLTypes.HITLCreateInput` lands on the
wire — including the new `notify_url` field added in
[#2419](https://github.com/getaxonflow/axonflow-enterprise/issues/2419)
— then asserts the SDK parses the platform's `APIResponse{success,
data}` envelope back into a populated `HITLApprovalRequest`.

No WireMock, no JUnit `@Test`, no test doubles — runs the production
OkHttp transport against an in-process JDK `HttpServer`, which is what
the `runtime-e2e/` DoD gate is asking for.

## Usage

```bash
./mvnw -q package -DskipTests
java -cp "target/axonflow-sdk-8.2.0.jar:$(./mvnw -q dependency:build-classpath -Dmdep.outputFile=/dev/stderr 2>&1 | tail -1)" \
runtime-e2e/create_hitl_request/CreateHITLRequestTest.java
```

Exits `0` on PASS, `1` on FAIL. Prints captured wire body + parsed
response fields on success for human-readable confirmation.

## Companion unit coverage

`src/test/java/com/getaxonflow/sdk/HITLTest.java`
`@Nested CreateHITLRequest` exercises the same surface through
WireMock for eight scenarios (happy path full-fields, minimal
required-fields, bad-`notifyUrl`-scheme 400 propagation, 401
propagation, connect-failure propagation, and the three
`IllegalArgumentException` guards for missing required fields). The
runtime proof here is the redundant real-stack confirmation.
61 changes: 61 additions & 0 deletions src/main/java/com/getaxonflow/sdk/AxonFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -6416,6 +6416,67 @@ public CompletableFuture<HITLApprovalRequest> getHITLRequestAsync(String request
return CompletableFuture.supplyAsync(() -> getHITLRequest(requestId), asyncExecutor);
}

/**
* Creates a new HITL approval request via {@code POST /api/v1/hitl/queue}.
*
* <p><b>Enterprise Feature:</b> Requires AxonFlow Enterprise license. The platform returns 403
* with {@code ErrHITLApprovalDisabledByTier} when called against a community tier that hasn't
* enabled HITL, and 401 when credentials are invalid.
*
* <p>This is the explicit row-creation step for callers that detect {@code require_approval}
* from a separate gate ({@code pre_check}, {@code check_tool_input}, MAP plan approvals) and
* want the row enqueued so a reviewer can act on it. After creating, either poll
* {@link #getHITLRequest(String)} until terminal state, or supply
* {@link HITLCreateInput#setNotifyUrl(String) notifyUrl} so the platform fires a signed webhook
* on the transition (n8n Wait-node "On Webhook Call" pattern, ADK plugin polling-free mode).
*
* <p>{@code clientId}, {@code originalQuery}, and {@code requestType} are required; all other
* fields are optional. Bad {@code notifyUrl} schemes are rejected by the platform with HTTP
* 400 (surfaced here via {@link AxonFlowException}); only {@code https://} (and {@code http://}
* for self-hosted local-dev) are accepted.
*
* @param input the create-request input
* @return the created approval request with {@code requestId} populated
* @throws AxonFlowException if validation or the platform call fails
*/
public HITLApprovalRequest createHITLRequest(HITLCreateInput input) {
Objects.requireNonNull(input, "input cannot be null");
if (input.getClientId() == null || input.getClientId().isEmpty()) {
throw new IllegalArgumentException("client_id is required");
}
if (input.getOriginalQuery() == null || input.getOriginalQuery().isEmpty()) {
throw new IllegalArgumentException("original_query is required");
}
if (input.getRequestType() == null || input.getRequestType().isEmpty()) {
throw new IllegalArgumentException("request_type is required");
}

return retryExecutor.execute(
() -> {
Request httpRequest = buildOrchestratorRequest("POST", "/api/v1/hitl/queue", input);
try (Response response = executeHttp(httpClient, httpRequest)) {
JsonNode node = parseResponseNode(response);

// Server wraps response: {"success": true, "data": {...}}
if (node.has("data") && node.get("data").isObject()) {
return objectMapper.treeToValue(node.get("data"), HITLApprovalRequest.class);
}
return objectMapper.treeToValue(node, HITLApprovalRequest.class);
}
},
"createHITLRequest");
}

/**
* Asynchronously creates a new HITL approval request.
*
* @param input the create-request input
* @return a future containing the created approval request
*/
public CompletableFuture<HITLApprovalRequest> createHITLRequestAsync(HITLCreateInput input) {
return CompletableFuture.supplyAsync(() -> createHITLRequest(input), asyncExecutor);
}

/**
* Approves a HITL approval request.
*
Expand Down
Loading
Loading