tasks(decomp): ADR-049 streaming handler — 8 atomic tasks + gitignore .worktrees/
Decompose the ADR-049 streaming handler work into 8 dependency-ordered tasks: - call/registry/streaming-handler-handlerkind (foundation: StreamingHandler, HandlerKind, ResponseStream, INVALID_OPERATION_TYPE, migrate all sites) - call/registry/invoke-streaming (OperationRegistry::invoke_streaming) - call/protocol/dispatch-streaming-branch (server-side op_type branch) - call/client/from-call-streaming-forwarding (Subscription → subscribe()) - http/gateway/invoke-streaming (GatewayDispatch::invoke_streaming) - http/server/subscribe-sse-streaming (/subscribe pipes BoxStream to SSE) - http/adapters/from-openapi-sse-streaming (SSE → StreamingHandler) - review-streaming-impl (phase review checkpoint) Validated with taskgraph: 86 tasks, no cycles. Also ignore .worktrees/ so agents' worktree workspaces don't leak into git status.
This commit is contained in:
243
tasks/http/adapters/from-openapi-sse-streaming.md
Normal file
243
tasks/http/adapters/from-openapi-sse-streaming.md
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
id: http/adapters/from-openapi-sse-streaming
|
||||
name: Implement from_openapi Subscription forwarding as StreamingHandler (SSE response → BoxStream<ResponseEnvelope>)
|
||||
status: pending
|
||||
depends_on: [call/registry/streaming-handler-handlerkind]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Branch `from_openapi`'s forwarding handler construction on `op_type` so that a
|
||||
`Subscription` op (detected via `text/event-stream` response content type)
|
||||
registers a `StreamingHandler` (`HandlerKind::Stream`) that streams the SSE
|
||||
response chunks as `ResponseEnvelope::ok()` items. `Query`/`Mutation` ops keep
|
||||
the existing `Handler` (`HandlerKind::Once`) that returns a single
|
||||
`ResponseEnvelope`. This closes the gap where a `from_openapi`-imported
|
||||
`Subscription` returned only the last SSE event.
|
||||
|
||||
This task depends on `call/registry/streaming-handler-handlerkind` (which
|
||||
introduces `HandlerKind::Stream` and `make_streaming_handler`). The existing
|
||||
`from_openapi` code already detects `Subscription` (`detect_op_type` checks for
|
||||
`text/event-stream`) and has an SSE parser (`parse_sse_frames`); this task
|
||||
rewires the subscription path from "collect all events, return last" to "stream
|
||||
events as they arrive".
|
||||
|
||||
### The branch in build_registration
|
||||
|
||||
`build_registration` currently always builds a `Handler` (via `make_handler`) and
|
||||
wraps in `HandlerKind::Once` (after `streaming-handler-handlerkind`). Branch on
|
||||
`op_type`:
|
||||
|
||||
- `Query` / `Mutation` → existing `make_handler` + `forward()` (single response),
|
||||
`HandlerKind::Once`
|
||||
- `Subscription` → new `make_streaming_handler` + `forward_stream()` (SSE
|
||||
streaming), `HandlerKind::Stream`
|
||||
|
||||
The `op_type` is already computed by `detect_op_type` and available in
|
||||
`build_registration`. The `HandlerRegistration::new()` call at the end wraps in
|
||||
the right `HandlerKind` based on `op_type`.
|
||||
|
||||
### forward_stream() — the streaming forward function
|
||||
|
||||
```rust
|
||||
async fn forward_stream(
|
||||
http_client: &Arc<SharedHttpClient>,
|
||||
base_url: &str,
|
||||
path_template: &str,
|
||||
method: &str,
|
||||
auth_scheme: &Option<HttpAuthScheme>,
|
||||
default_headers: &HashMap<String, String>,
|
||||
namespace: &str,
|
||||
error_status_codes: &[(u16, String)],
|
||||
input: Value,
|
||||
context: OperationContext,
|
||||
) -> ResponseStream {
|
||||
let request_id = context.request_id.clone();
|
||||
|
||||
// 1. Build the request (same as forward())
|
||||
let (http_method, url, body, headers) = match build_request(...) {
|
||||
Ok(parts) => parts,
|
||||
Err(err) => {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, err)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Send with Accept: text/event-stream
|
||||
let request_builder = http_client.client()
|
||||
.request(http_method, url.as_str())
|
||||
.headers(headers)
|
||||
.header(ACCEPT, "text/event-stream");
|
||||
let request_builder = match body.as_ref() {
|
||||
Some(b) => request_builder.body(serde_json::to_string(b).unwrap_or("null".to_string())),
|
||||
None => request_builder,
|
||||
};
|
||||
|
||||
let response: reqwest::Response = match request_builder.send().await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, CallError::internal(format!("HTTP request failed: {err}")))
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// Non-2xx → single error envelope, stream ends
|
||||
let code = error_status_codes.iter()
|
||||
.find(|(s, _)| *s == status.as_u16())
|
||||
.map(|(_, c)| c.clone())
|
||||
.unwrap_or_else(|| format!("HTTP_{}", status.as_u16()));
|
||||
let message = format!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
|
||||
return Box::pin(stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, CallError::new(code, message, false))
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Stream the SSE chunks → ResponseEnvelope::ok() per data: frame
|
||||
let request_id_stream = request_id.clone();
|
||||
let sse_stream = response.bytes_stream()
|
||||
.scan(String::new(), move |buffer, chunk_result| {
|
||||
// Parse SSE frames from the chunk, emit each as a ResponseEnvelope::ok()
|
||||
// This is the streaming analogue of stream_subscription()
|
||||
let request_id = request_id_stream.clone();
|
||||
async move {
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
let (events, remaining) = parse_sse_frames(buffer);
|
||||
*buffer = remaining;
|
||||
// Emit each event as a ResponseEnvelope::ok()
|
||||
let envelopes: Vec<ResponseEnvelope> = events.into_iter()
|
||||
.map(|e| {
|
||||
let parsed = if e.data.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str(&e.data).unwrap_or(Value::String(e.data.clone()))
|
||||
};
|
||||
ResponseEnvelope::ok(&request_id, parsed)
|
||||
})
|
||||
.collect();
|
||||
Some((envelopes,)) // yield the batch
|
||||
}
|
||||
Err(err) => {
|
||||
let error = CallError::internal(format!("SSE stream error: {err}"));
|
||||
Some(vec![ResponseEnvelope::error(request_id, error)])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.flat_map(|envelopes| stream::iter(envelopes));
|
||||
|
||||
Box::pin(sse_stream)
|
||||
}
|
||||
```
|
||||
|
||||
The exact combinator shape (`scan` + `flat_map`, or a custom `Stream` impl, or
|
||||
`unfold`) is an implementation detail — the contract is: each SSE `data:` frame
|
||||
becomes a `ResponseEnvelope::ok()`; an HTTP error (non-2xx) becomes a single
|
||||
`ResponseEnvelope::error()` and ends the stream; SSE stream end ends the
|
||||
`ResponseStream` (→ `call.completed` on the wire). Reuse the existing
|
||||
`parse_sse_frames` parser — it already handles multi-event buffers, partial
|
||||
trailing lines, comments, multi-line data, BOM.
|
||||
|
||||
### Remove stream_subscription() (the collect-all placeholder)
|
||||
|
||||
The existing `stream_subscription()` collects all SSE events and returns the
|
||||
last one as a single `ResponseEnvelope`. This is the placeholder that
|
||||
truncates. Remove it (or repurpose its SSE-parsing logic into the streaming
|
||||
`forward_stream`). The `parse_sse_frames` function stays (it's reused by
|
||||
`forward_stream`); only the collect-all `stream_subscription` wrapper goes.
|
||||
|
||||
### build_registration wiring
|
||||
|
||||
```rust
|
||||
let handler = if op_type == OperationType::Subscription {
|
||||
// Streaming handler — HandlerKind::Stream
|
||||
let stream_handler = make_streaming_handler(move |input, context| {
|
||||
// clone captured vars
|
||||
async move {
|
||||
forward_stream(&http_client, &base_url, &path_template, &method_upper,
|
||||
&auth_scheme, &default_headers, &namespace, &error_status_codes,
|
||||
input, context).await
|
||||
}
|
||||
});
|
||||
HandlerKind::Stream(stream_handler)
|
||||
} else {
|
||||
// Request/response handler — HandlerKind::Once (existing)
|
||||
let once_handler = make_handler(move |input, context| {
|
||||
// clone captured vars
|
||||
async move {
|
||||
forward(&http_client, &base_url, &path_template, &method_upper,
|
||||
&auth_scheme, &default_headers, &namespace, &error_status_codes,
|
||||
op_type, input, context).await
|
||||
}
|
||||
});
|
||||
HandlerKind::Once(once_handler)
|
||||
};
|
||||
|
||||
HandlerRegistration::new(spec, handler, OperationProvenance::FromOpenAPI, None, None, capabilities)
|
||||
```
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `from_mcp` changes.** `from_mcp` handlers are always `HandlerKind::Once`
|
||||
(MCP tools are request/response — ADR-041; ADR-049 confirms this is unchanged).
|
||||
- **No gateway changes.** The gateway `/subscribe` SSE path is
|
||||
`http/server/subscribe-sse-streaming`.
|
||||
- **No `OperationRegistry` changes.** `invoke_streaming()` is provided by
|
||||
`call/registry/invoke-streaming`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `build_registration` branches on `op_type`: `Subscription` →
|
||||
`HandlerKind::Stream` (streaming forward), `Query`/`Mutation` →
|
||||
`HandlerKind::Once` (existing forward)
|
||||
- [ ] `forward_stream()` streams SSE chunks as `ResponseEnvelope::ok()` items
|
||||
- [ ] Each SSE `data:` frame → one `ResponseEnvelope::ok()`
|
||||
- [ ] HTTP error (non-2xx) → single `ResponseEnvelope::error()`, stream ends
|
||||
- [ ] SSE stream end → `ResponseStream` ends (→ `call.completed` on wire)
|
||||
- [ ] `parse_sse_frames` reused (multi-event, partial trailing, comments,
|
||||
multi-line data, BOM — all handled)
|
||||
- [ ] `stream_subscription()` (collect-all placeholder) removed or repurposed
|
||||
- [ ] `Query`/`Mutation` forwarding unchanged (existing `forward()` path)
|
||||
- [ ] `Accept: text/event-stream` header sent for Subscription requests
|
||||
- [ ] Unit test: `Subscription` op registration is `HandlerKind::Stream`
|
||||
- [ ] Unit test: `Query` op registration is `HandlerKind::Once` (unchanged)
|
||||
- [ ] Integration test: `Subscription` forwarding streams multiple
|
||||
`ResponseEnvelope::ok()` items from an SSE server (one per `data:` frame)
|
||||
- [ ] Integration test: `Subscription` forwarding on HTTP error → one
|
||||
`ResponseEnvelope::error()`, stream ends
|
||||
- [ ] Integration test: `Query` forwarding unchanged (single response)
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §9 (from_openapi SSE forwarding)
|
||||
- docs/architecture/crates/http/http-adapters.md — §Forwarding handler (Subscription → HandlerKind::Stream, SSE → BoxStream)
|
||||
- docs/architecture/crates/http/http-mcp.md — from_mcp handlers always HandlerKind::Once (unchanged)
|
||||
|
||||
## Notes
|
||||
|
||||
> The existing `stream_subscription()` is the placeholder that truncates — it
|
||||
> collects all SSE events and returns the last. Replace it with `forward_stream()`
|
||||
> that yields each SSE event as a stream item. Reuse `parse_sse_frames` (it's
|
||||
> already correct for multi-event buffers, partial lines, comments, BOM). The
|
||||
> combinator shape (`scan` + `flat_map`, `unfold`, or custom `Stream`) is an
|
||||
> implementation detail — the contract is one `ResponseEnvelope::ok()` per
|
||||
> `data:` frame, error on HTTP failure, end on SSE close. `from_mcp` is
|
||||
> unchanged — MCP tools are request/response (ADR-041), always
|
||||
> `HandlerKind::Once`. The `futures` crate's `StreamExt::scan` / `flat_map` /
|
||||
> `unfold` are the likely tools.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
130
tasks/http/gateway/invoke-streaming.md
Normal file
130
tasks/http/gateway/invoke-streaming.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
id: http/gateway/invoke-streaming
|
||||
name: Implement GatewayDispatch::invoke_streaming() returning BoxStream<ResponseEnvelope>
|
||||
status: pending
|
||||
depends_on: [call/registry/invoke-streaming]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Add `GatewayDispatch::invoke_streaming()` — the streaming analogue of
|
||||
`invoke()`, returning a `BoxStream<ResponseEnvelope>`. The security invariants
|
||||
are identical to `invoke()`: `internal: false`, `forwarded_for: None`, same
|
||||
capabilities, same `scoped_env`, same ACL check before dispatch. The two methods
|
||||
diverge only on the return shape (stream vs single envelope). The HTTP
|
||||
`/subscribe` handler calls this and pipes the stream to SSE.
|
||||
|
||||
This task depends on `call/registry/invoke-streaming` (which provides
|
||||
`OperationRegistry::invoke_streaming()`). It adds the `GatewayDispatch` method
|
||||
only — the `/subscribe` SSE wiring is `http/server/subscribe-sse-streaming`.
|
||||
|
||||
### invoke_streaming()
|
||||
|
||||
```rust
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
impl GatewayDispatch {
|
||||
/// Invoke a Subscription operation as a wire-ingress caller. The streaming
|
||||
/// analogue of `invoke()`. Security invariants identical to `invoke()`:
|
||||
/// `internal: false`, `forwarded_for: None`, same capabilities, same
|
||||
/// scoped_env, same ACL. Diverges only on return shape (stream vs single
|
||||
/// envelope). Returns a `BoxStream<ResponseEnvelope>`; the `/subscribe`
|
||||
/// handler pipes it to SSE.
|
||||
pub async fn invoke_streaming(
|
||||
&self,
|
||||
identity: Option<Identity>,
|
||||
op: &str,
|
||||
input: Value,
|
||||
) -> BoxStream<'static, ResponseEnvelope> {
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let context = self.build_root_context(&request_id, &operation_name, identity);
|
||||
// The registry's invoke_streaming returns ResponseStream
|
||||
// (Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>), which IS
|
||||
// BoxStream<'static, ResponseEnvelope>. Box::pin / BoxStream are the
|
||||
// same type — the alias just spells it out.
|
||||
self.registry.invoke_streaming(&operation_name, input, context)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`build_root_context` is reused unchanged — it constructs the root
|
||||
`OperationContext` with `internal: false`, `forwarded_for: None`, fresh
|
||||
`request_id`, deadline, registration bundle's `composition_authority` /
|
||||
`capabilities` / `scoped_env`. The security axis is provably identical between
|
||||
`invoke()` and `invoke_streaming()` because they share `build_root_context`.
|
||||
|
||||
### deadline: None for streaming
|
||||
|
||||
The spec says `deadline: None` for subscriptions (unbounded). `build_root_context`
|
||||
sets `deadline: Some(now + 30s)`. For the streaming path, set
|
||||
`context.deadline = None` after `build_root_context`, OR add a streaming flag to
|
||||
`build_root_context`. Coordinate with `call/protocol/dispatch-streaming-branch`
|
||||
which has the same concern — extract a shared approach (e.g., a
|
||||
`build_root_context_streaming` variant or a `deadline: None` override). The
|
||||
gateway and the call dispatcher both need `deadline: None` for subscriptions;
|
||||
don't duplicate the logic.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `/subscribe` SSE wiring.** That's `http/server/subscribe-sse-streaming`.
|
||||
- **No `OperationRegistry` changes.** `invoke_streaming()` is provided by
|
||||
`call/registry/invoke-streaming`.
|
||||
- **No `to_mcp` changes.** MCP excludes `Subscription` (ADR-041); `to_mcp` never
|
||||
calls `invoke_streaming()`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `GatewayDispatch::invoke_streaming()` method exists
|
||||
- [ ] Returns `BoxStream<'static, ResponseEnvelope>` (or `ResponseStream` —
|
||||
same type)
|
||||
- [ ] Builds root `OperationContext` via `build_root_context` (same as `invoke()`)
|
||||
- [ ] Root context has `internal: false`, `forwarded_for: None`, fresh
|
||||
`request_id`
|
||||
- [ ] `handler_identity`, `capabilities`, `scoped_env` from registration bundle
|
||||
(same as `invoke()`)
|
||||
- [ ] `deadline: None` for the streaming path (unbounded subscriptions)
|
||||
- [ ] Calls `OperationRegistry::invoke_streaming()`
|
||||
- [ ] Security invariants identical to `invoke()` (shared `build_root_context`)
|
||||
- [ ] Unit test: `invoke_streaming()` on a registered `Subscription` op returns
|
||||
the handler's stream
|
||||
- [ ] Unit test: `invoke_streaming()` on unknown op returns a stream yielding
|
||||
one `NOT_FOUND`
|
||||
- [ ] Unit test: `invoke_streaming()` on Internal op from external returns
|
||||
stream yielding one `NOT_FOUND` (not leaked)
|
||||
- [ ] Unit test: `invoke_streaming()` with `None` identity + restricted op
|
||||
returns stream yielding one `FORBIDDEN`
|
||||
- [ ] Unit test: `invoke_streaming()` on a `Query` op returns stream yielding
|
||||
one `INVALID_OPERATION_TYPE`
|
||||
- [ ] Unit test: `invoke()` (existing) on a `Subscription` op returns
|
||||
`INVALID_OPERATION_TYPE` (verifies the guard from
|
||||
`streaming-handler-handlerkind` holds through the gateway)
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §7 (GatewayDispatch::invoke_streaming)
|
||||
- docs/architecture/crates/http/http-server.md — §Streaming projection (invoke_streaming security invariants identical to invoke)
|
||||
- docs/architecture/crates/call/operation-registry.md — §OperationRegistry (invoke_streaming)
|
||||
|
||||
## Notes
|
||||
|
||||
> The security axis MUST be provably identical between `invoke()` and
|
||||
> `invoke_streaming()` — they share `build_root_context`. The two methods
|
||||
> diverge only on the return shape. `deadline: None` for subscriptions is a
|
||||
> shared concern with `call/protocol/dispatch-streaming-branch` — extract a
|
||||
> shared approach (a streaming-variant of `build_root_context` or a
|
||||
> `deadline: None` override) rather than duplicating. `to_mcp` never calls
|
||||
> `invoke_streaming()` (MCP excludes `Subscription` — ADR-041); do not add
|
||||
> streaming to the MCP gateway. The `futures` crate is already a dependency of
|
||||
> `alknet-http`.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
156
tasks/http/server/subscribe-sse-streaming.md
Normal file
156
tasks/http/server/subscribe-sse-streaming.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
id: http/server/subscribe-sse-streaming
|
||||
name: Wire /subscribe handler to GatewayDispatch::invoke_streaming() and pipe BoxStream to SSE
|
||||
status: pending
|
||||
depends_on: [http/gateway/invoke-streaming]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Replace the `/subscribe` handler's one-event placeholder
|
||||
(`subscribe_stream_from_envelope`, which calls `GatewayDispatch::invoke()` and
|
||||
wraps the single `ResponseEnvelope` in a one-event SSE stream) with the real
|
||||
streaming path: call `GatewayDispatch::invoke_streaming()` and pipe the
|
||||
`BoxStream<ResponseEnvelope>` to SSE. Each `Ok(value)` → SSE `data:` frame;
|
||||
`Err` → SSE error event + close (terminal); natural stream end → close (normal
|
||||
end, corresponds to `call.completed` on the wire). On `call.aborted` or HTTP
|
||||
client disconnect, drop the stream (Drop releases handler resources, abort
|
||||
cascade runs per ADR-016).
|
||||
|
||||
This task depends on `http/gateway/invoke-streaming` (which provides
|
||||
`GatewayDispatch::invoke_streaming()`). It rewrites `subscribe_handler` and
|
||||
removes the placeholder helpers.
|
||||
|
||||
### subscribe_handler rewrite
|
||||
|
||||
```rust
|
||||
pub(crate) async fn subscribe_handler(
|
||||
State(state): State<GatewayState>,
|
||||
ResolvedIdentity(identity): ResolvedIdentity,
|
||||
Json(request): Json<CallRequest>,
|
||||
) -> Sse<SubscribeStream> {
|
||||
let stream = if is_internal_op(&state.registry, &request.operation) {
|
||||
// Internal ops return NOT_FOUND (don't leak existence) — single error event
|
||||
subscribe_stream_internal_error(request.operation)
|
||||
} else {
|
||||
let dispatch = state.dispatch();
|
||||
let envelope_stream = dispatch
|
||||
.invoke_streaming(identity, &request.operation, request.input)
|
||||
.await;
|
||||
// Pipe the BoxStream<ResponseEnvelope> to SSE frames
|
||||
subscribe_stream_from_envelope_stream(envelope_stream)
|
||||
};
|
||||
Sse::new(stream)
|
||||
}
|
||||
```
|
||||
|
||||
### subscribe_stream_from_envelope_stream
|
||||
|
||||
Map each `ResponseEnvelope` in the `BoxStream` to an SSE `Event`:
|
||||
|
||||
```rust
|
||||
fn subscribe_stream_from_envelope_stream(
|
||||
stream: BoxStream<'static, ResponseEnvelope>,
|
||||
) -> SubscribeStream {
|
||||
Box::pin(stream.map(|envelope| {
|
||||
match envelope.result {
|
||||
Ok(output) => {
|
||||
let data = serde_json::to_string(&output)
|
||||
.unwrap_or_else(|_| "null".to_string());
|
||||
Ok(Event::default().data(data))
|
||||
}
|
||||
Err(error) => {
|
||||
let payload = serde_json::to_value(&error).unwrap_or(Value::Null);
|
||||
let data = serde_json::to_string(&payload)
|
||||
.unwrap_or_else(|_| "null".to_string());
|
||||
Ok(Event::default().event("error").data(data))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
The `Err` case produces an SSE error event — the stream ends after it (the
|
||||
`StreamingHandler`'s contract: `Err` is terminal). The natural stream end
|
||||
(stream yields `None`) closes the SSE stream (axum's `Sse` wrapper handles the
|
||||
close when the underlying stream ends).
|
||||
|
||||
### Remove the placeholder
|
||||
|
||||
Delete `subscribe_stream_from_envelope` (the one-event placeholder) and
|
||||
`envelope_to_sse_stream` (the single-envelope-to-stream helper). The new
|
||||
`subscribe_stream_from_envelope_stream` replaces them. Keep
|
||||
`subscribe_stream_internal_error` (Internal ops still return a single
|
||||
`NOT_FOUND` error event — they don't reach `invoke_streaming()`).
|
||||
|
||||
### Client disconnect / abort
|
||||
|
||||
axum's `Sse` response detects when the HTTP client disconnects (the response
|
||||
writer closes) and drops the stream future. `Drop` releases the handler's
|
||||
resources, and the abort cascade runs per ADR-016. No explicit disconnect
|
||||
handling is needed — Rust's `Drop` + axum's response-drop handle it. Verify the
|
||||
stream is dropped (not leaked) on disconnect.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- **No `GatewayDispatch` changes.** `invoke_streaming()` is provided by
|
||||
`http/gateway/invoke-streaming`.
|
||||
- **No `to_mcp` changes.** MCP has no `/subscribe` equivalent (ADR-041).
|
||||
- **No `from_openapi` changes.** `from_openapi` SSE forwarding is
|
||||
`http/adapters/from-openapi-sse-streaming`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `subscribe_handler` calls `GatewayDispatch::invoke_streaming()` (not
|
||||
`invoke()`)
|
||||
- [ ] `subscribe_stream_from_envelope_stream` maps `BoxStream<ResponseEnvelope>`
|
||||
to SSE `Event`s
|
||||
- [ ] `Ok(value)` → SSE `data:` frame with output serialized as JSON
|
||||
- [ ] `Err` → SSE error event (`event: error`) with `CallError` serialized, then
|
||||
stream ends (terminal)
|
||||
- [ ] Natural stream end → SSE stream closes (normal end)
|
||||
- [ ] Internal op → single `NOT_FOUND` error event (unchanged —
|
||||
`subscribe_stream_internal_error` kept)
|
||||
- [ ] Client disconnect → stream dropped (Drop releases resources; abort cascade)
|
||||
- [ ] Placeholder helpers (`subscribe_stream_from_envelope`,
|
||||
`envelope_to_sse_stream`) removed
|
||||
- [ ] `SubscribeStream` type alias still `BoxStream<'static, Result<Event, Infallible>>`
|
||||
- [ ] Unit test: `/subscribe` on a `Subscription` op streams multiple `data:`
|
||||
frames (one per `call.responded`)
|
||||
- [ ] Unit test: `/subscribe` on a `Subscription` op that yields `Err` → one
|
||||
`event:error` frame, then stream closes
|
||||
- [ ] Unit test: `/subscribe` on Internal op → `event:error` with `NOT_FOUND`
|
||||
(unchanged)
|
||||
- [ ] Unit test: `/subscribe` on unknown op → `event:error` with `NOT_FOUND`
|
||||
- [ ] Unit test: `/subscribe` on `Query` op → `event:error` with
|
||||
`INVALID_OPERATION_TYPE` (the guard holds through the gateway)
|
||||
- [ ] Unit test: response `Content-Type` is `text/event-stream`
|
||||
- [ ] `cargo test -p alknet-http` succeeds
|
||||
- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings
|
||||
- [ ] `cargo fmt --check -p alknet-http` passes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/049-streaming-handler-for-subscriptions.md — ADR-049 §7 (HTTP /subscribe pipes BoxStream to SSE)
|
||||
- docs/architecture/crates/http/http-server.md — §Streaming projection (SSE — the gateway's /subscribe)
|
||||
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (stream drop on disconnect/abort)
|
||||
|
||||
## Notes
|
||||
|
||||
> This replaces the one-event placeholder with the real streaming path. The
|
||||
> `Err` envelope is terminal — the SSE stream ends after the error event (no
|
||||
> `data:` frame after an `event:error`). Natural stream end closes the SSE
|
||||
> stream (axum handles the close when the underlying stream ends). Client
|
||||
> disconnect drops the stream future via Rust's `Drop` — no explicit handling
|
||||
> needed. Keep `subscribe_stream_internal_error` (Internal ops return
|
||||
> `NOT_FOUND` without reaching `invoke_streaming()` — they don't leak
|
||||
> existence). The `futures::StreamExt::map` combinator is the tool for mapping
|
||||
> the envelope stream to SSE events.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in New Issue
Block a user