feat(compose): stream output lines and fall back to stderr - #99
feat(compose): stream output lines and fall back to stderr#99strausmann wants to merge 6 commits into
Conversation
Execute now accepts an onLine callback (nil preserves today's behavior). teeLines tees stdout/stderr through an io.Pipe + bufio.Scanner so complete lines reach the callback as the compose subprocess produces them, while the existing bytes.Buffer capture (and thus result.Output) is unchanged. Both streams are wired to onLine, not stderr alone: without a build compose writes to stderr only, but with a build the bulk lands on stdout (measured in task 0). Missing that would silently drop build output. result.Output now falls back to stderr whenever stdout is empty, merged with (not layered next to) the existing ps/JSON special case. http.go and edge/client.go pass nil for now (task 7 wires a real callback into the edge streaming path).
Without this test, a regression that drops cmd.Stdout wiring and hooks stderr only would go unnoticed: the earlier tests use "ps", which never writes to stdout in the first place, so they cannot exercise this path. Builds a trivial one-line image and checks the callback sees a marker the build wrote to stdout. Skips if Docker is unavailable or unreachable.
…d line io.Pipe is synchronous and bufio.Scanner only flushes a trailing line without a newline on EOF, so the returned close() has to trigger that EOF. A no-op close would drop the last line silently -- often the most important one, e.g. a build's final status line -- with no other test catching it (confirmed by mutating close() to a no-op: this test goes red, the others stay green).
This package's other tests intentionally have no external dependency (compose CLI failing fast on its own is enough). The build test is the exception: it needs Docker to actually build an image. Left unconditional, it would be the first test in this repo to spin up a real image build in an external contributor's CI -- a fair objection unrelated to what this change is about. Gated behind HAWSER_TEST_DOCKER=1, on top of the existing docker/daemon-availability skip checks; both are legitimate reasons to skip.
|
Really nice work on this, the streaming path and the back-compat story (opt-in One thing before this goes in though: It's not just theoretical — a We'll take care of the fix on our end. Simplest thing that worked for us is dropping The rest is good to go from our review — #96 and #98 both look solid too. Heads-up on the merge: #99 and #96/#98 both add a fresh |
|
Thanks for the careful read — you're right, and I reproduced it independently before touching anything. I lifted Every link in your chain holds up:
Please go ahead and push the fix — you've already verified the 2 MB case, and
Noted on the merge order too: #99 and #96/#98 both add |
|
@jotka — small correction to my comment above, and since an edit doesn't notify: I originally wrote that we'd take the fix ourselves. Scratch that, please go ahead and push it — you have it verified already, and there's no sense in us rebuilding it. I've edited that comment to list the three things from our findings we'd like folded in: |
|
Thanks — we will have all three folded in:
running the full ci/cd suite now (takes ~3hrs) with new agents, with all of these PRs and fixes on top. will release soon. |
There was a problem hiding this comment.
🟡 Changes recommended
New tests can fail or race in environments without docker compose installed and under concurrent callbacks, and the streamed line handling should trim CRLF correctly to avoid incorrect output.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR extends the agent-side compose execution path to support live, per-line output streaming (when requested) and fixes “blank output” results by falling back to stderr when stdout is empty, aligning agent behavior with Dockhand’s local behavior.
Changes:
- Updated
ComposeClient.Executeto optionally stream stdout/stderr lines via a callback while preserving buffered output behavior when no callback is provided. - Added Edge compose request support for optional streaming (
streamOutput) and introduced acomposeExecutorinterface to enable non-shelling test doubles. - Added tests covering line streaming behavior, stderr fallback, and teeLines edge cases.
File summaries
| File | Description |
|---|---|
| internal/server/http.go | Updates REST compose execution call to pass the new onLine parameter (nil for REST). |
| internal/edge/client.go | Introduces composeExecutor and streams compose output lines over WebSocket only when streamOutput is requested. |
| internal/edge/client_test.go | Adds WebSocket-based tests validating that compose streaming messages are only sent when requested. |
| internal/docker/compose.go | Implements teeLines, streams output lines, adds WaitDelay, and falls back result.Output to stderr when stdout is empty. |
| internal/docker/compose_test.go | Adds tests for streaming, stderr fallback, build-output-on-stdout behavior (gated), and teeLines edge cases. |
Review details
Suppressed comments (1)
internal/docker/compose_test.go:48
- TestOutputFallsBackToStderr assumes docker compose is installed; if detectComposeCommand fails, Execute returns early and the assertion fails even though the behavior under test isn't reachable. Skipping when compose isn't available keeps this unit test environment-independent (it doesn't otherwise require a Docker daemon).
c := NewComposeClient("", t.TempDir())
res, err := c.Execute(context.Background(), &ComposeOperation{Operation: "ps"}, nil)
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| line, err := br.ReadString('\n') | ||
| if len(line) > 0 { | ||
| onLine(strings.TrimRight(line, "\n")) | ||
| } |
| var lines []string | ||
| c := NewComposeClient("", t.TempDir()) | ||
| op := &ComposeOperation{Operation: "ps"} | ||
| _, err := c.Execute(context.Background(), op, func(l string) { lines = append(lines, l) }) |
| // Execute runs a Docker Compose operation. onLine, if non-nil, is invoked | ||
| // with each complete line of stdout/stderr as the compose command produces | ||
| // it (interleaved across both streams, same as a terminal would show them). | ||
| // Pass nil for today's behavior: buffered output only, returned in the | ||
| // result once the command has finished. |
What this changes
Two things in the compose path, both needed before Dockhand can show a live console view for agent-managed environments (Finsys/dockhand#506, Finsys/dockhand#1499).
1.
Execute()can report lines as they arriveinternal/docker/compose.gobuffered stdout/stderr intobytes.Bufferand returned one block when the process exited. It now takes an optional line callback. Without a callback the behaviour is unchanged — same buffering, same return value.internal/edge/client.go'shandleComposeRequestsets that callback when the caller asks for it (newstreamOutputfield in the payload) and sends one message per line, modelled onhandleStreamingRequestin the same file.2.
result.Outputfalls back to stderrresult.Outputwasstdout.String()with no stderr fallback. Compose writes to stderr, so on the agent path a successful run returned an effectively empty output and Dockhand fell back to its placeholder text almost every time. The local path in Dockhand already doesstdout || stderr || placeholder; this brings the agent path in line.This is a small fix with a visible effect on its own — even without any Dockhand change, agent-side compose results stop coming back blank.
Compatibility
streamOutputis a new optional payload field. Agents built before this change ignore it (json.UnmarshalwithoutDisallowUnknownFields), and Dockhand reacts to whatever arrives — either line messages followed by the result, or just the result. No version negotiation.Conversely, an agent with this change talking to a Dockhand without it simply never gets asked to stream.
Related
RequestTimeoutmid-stream, which with live output becomes very visible: you watch lines arrive and then it stops with no explanation. They are independent of this PR but land in the same area.Testing
go build ./...,go vet ./...,go test ./...all clean.Added coverage for:
teeLinesflushing a final unterminated line on close, build output landing on stdout, and the stderr fallback. The docker-dependent build test is gated behind an env switch so it does not require a daemon in CI.