Skip to content

feat(go-sdk): WatchLogs - #10

Open
rhuss wants to merge 3 commits into
mainfrom
port/3662
Open

rhuss wants to merge 3 commits into
mainfrom
port/3662

Conversation

@rhuss

@rhuss rhuss commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Originally NVIDIA/OpenShell#3662 by @letv1nnn. Thanks for the contribution!

Summary

Add WatchLogs to the Go SDK, a loss-aware, resumable log and platform event stream helper that mirrors the Rust SDK's reconnect, backoff, and cursor-resume semantics. Recoverable loss surfaces as WatchLogKindWarning events; unrecoverable loss (trimmed cursor) terminates with ErrorOutOfRange.

Related Issue

Refs NVIDIA/OpenShell#3055 (partial, item B of NVIDIA/OpenShell#3209 TODO).

Changes

  • New API: SandboxInterface.WatchLogs(ctx, workspace, name, opts...) returns WatchInterface[*WatchLogEvent] with transparent reconnect on Unavailable, capped exponential backoff (100ms→2s) reset on each delivered event, and cursor-aware replay from the highest observed cursor.
  • Domain types: PlatformEvent, WatchLogEvent{Kind, Log, Event, Warning, Cursor}, WatchLogKind{Log,Event,Warning}, WatchLogsOptions{FollowLogs, FollowEvents, LogSources, LogMinLevel, ResumeAfterCursor, LogTailLines, EventTail}.
  • Error code: New ErrorOutOfRange to distinguish terminal gaps (cursor trimmed/from dead cursor space) from invalid arguments. Remapped gRPC OutOfRange from ErrorInvalidArgument → ErrorOutOfRange.
  • Converter: PlatformEventFromProto, WatchLogEventFromProto with deep-copy semantics; returns nil for non-resumable payloads (status snapshots, draft policy updates).
  • Fake client: Returns ErrorUnimplemented (matching GetLogs, no replayable buffer contract).
  • Tests: 13 integration tests covering payload delivery, reconnect-no-loss/no-dup, high-water-mark cursor semantics, warning passthrough, OUT_OF_RANGE terminal (no retry), backoff reset, Stop/context cancellation, and error cases. 8 converter tests + error mapping + code string tests.
  • Docs: WatchLogs section in docs/src/api/sandboxes.md with usage examples, loss semantics, and cursor contract. Error-handling doc updated with IsOutOfRange predicate.

Testing

  • mise run pre-commit passes
  • Unit tests added/updated
  • E2E tests added/updated (if applicable)

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable)

Port note

Ported from NVIDIA/OpenShell#3662 by @letv1nnn after the Go SDK moved out of NVIDIA/OpenShell at 8ed8bf0c907fd06572d616d64f22ede915d0c10f. The commits are the original ones from the core PR at 6186bba777cb6b9af835441a26f6ea292d8f0a87, with the same authors and sign-offs; the only change is the module path (github.com/NVIDIA/OpenShell/sdk/go is now github.com/ai-openshell/sdk-go). The core PR changed only sdk/go, so nothing stays in core.

  • 99c3e8f feat(go-sdk): WatchLogs
  • 03b5219 fix(go-sdk): wait the WatchLogs reconnect backoff once and keep it cancellable
  • cfb737a fix(go-sdk): reset the WatchLogs backoff only after a delivered item

The two follow-up commits came after the description above was written. The Testing section refers to core's tasks.

letv1nnn and others added 3 commits November 4, 2026 14:33
Signed-off-by: Artem Lytvyn <alytvyn@redhat.com>
…ncellable

Signed-off-by: letv1nnn <letv1n2007@icloud.com>
Signed-off-by: letv1nnn <letv1n2007@icloud.com>
@rhuss rhuss added the port PR ported from NVIDIA/OpenShell with its original authorship label Nov 4, 2026
@rhuss rhuss mentioned this pull request Nov 4, 2026
5 of 6 tasks
@letv1nnn

letv1nnn commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

thanks for porting this! I'll follow up on any review here.

@rhuss rhuss left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc-review Summary

What Went Well

  • openshell/v1/sandbox_watch_logs.go runWatchLogs (lines 145-147) keeps the resume cursor as a high-water mark with a comment explaining out-of-order live delivery across the log and platform sources, so a later lower cursor cannot rewind the resume point. TestWatchLogs_CursorIsHighWaterMark sends cursor 5 then 2 before a drop and asserts the redial resumes from 5.
  • openshell/v1/sandbox_watch_logs.go retryWatchLogs and isRetryableWatchLogsError isolate the retry decision in one place and retry only on the raw gRPC Unavailable code. ResourceExhausted (also mapped to ErrorUnavailable), PermissionDenied and Unauthenticated stay terminal, and OUT_OF_RANGE is surfaced as a terminal EventError as the proto contract requires.
  • openshell/v1/watch.go emit and sleep (lines 38-62) select on the result channel, w.done and ctx.Done(), and sleep stops its timer with defer. Stop therefore interrupts both a blocked send and a backoff wait, verified by TestWatchLogs_StopInterruptsBackoff.
  • openshell/v1/sandbox_watch_logs.go runWatchLogs (lines 90-91) defers close(w.result) and w.cancel(), so every exit path releases the gRPC stream and the single producer closes the channel exactly once.
  • openshell/v1/sandbox_watch_logs.go line 139 resets the backoff only after a converted item is delivered, not after the skipped status snapshot, which delivers the stated fix commit. TestWatchLogs_BackoffGrowsWhenOnlySnapshotsArrive pins the 100/200/400ms sequence.
  • openshell/v1/internal/converter/watch_log.go WatchLogEventFromProto keeps all proto oneof dispatch in the converter and returns nil for non-stream payloads, with table coverage in TestWatchLogEventFromProtoSkipsNonStreamPayloads. PlatformEventFromProto copies the metadata map, and the request copies LogSources, so caller and SDK share no mutable state.
  • openshell/v1/sandbox_watch_logs.go WatchLogs resolves the sandbox with Get under the caller's workspace before opening the stream and passes namedWorkspaceScope(workspace) on every redial, consistent with the other workspace-scoped clients.
  • openshell/v1/types/errors.go appends ErrorOutOfRange at the end of the iota block, so existing ErrorCode values are unchanged, and adds a matching String case and IsOutOfRange helper.
  • openshell/v1/types/watch_log.go and openshell/v1/sandbox.go follow the established types package plus v1 alias pattern, and openshell/v1/fake/sandbox.go WatchLogs mirrors the fake GetLogs convention (closed client returns ErrorUnavailable, otherwise ErrorUnimplemented).
  • openshell/v1/sandbox_client_test.go mockSandboxServer.watchFunc/watchRequests clone every request per attempt, so tests can script each reconnect, and TestWatchLogs_OutOfRangeIsTerminal asserts IsOutOfRange, not IsInvalidArgument, and exactly one dial.

Findings

Severity File Description Source
Important openshell/v1/sandbox_watch_logs.go:145 Avoid advancing the resume cursor past undelivered events. codex
Minor openshell/v1/fake/sandbox.go:904 The new fake WatchLogs (Unimplemented, or Unavailable when the client is closed) has no tests... test-quality
Minor openshell/v1/sandbox.go:121 WatchLogs is added directly to the exported SandboxInterface, breaking every external... goal-alignment
Minor openshell/v1/sandbox_watch_logs.go:34 A package-level mutable function variable (testHookWatchLogsSleep) is added to production code... architecture
Minor openshell/v1/sandbox_watch_logs.go:120 The transparent-reconnect guarantee depends on stream.Recv returning Unavailable, but the client... production
Minor openshell/v1/sandbox_watch_logs.go:176 WatchLogs retries Unavailable forever with no signal to the consumer, so a gateway that stays... gator
Minor openshell/v1/sandbox_watch_logs.go:192 The status.Code fallback in isRetryableWatchLogsError is unreachable, because its only caller... architecture
Minor openshell/v1/sandbox_watch_logs_test.go:174 TestWatchLogs_RequestFields never asserts the request's Sandbox name or WorkspaceScope, and no... test-quality
Minor openshell/v1/sandbox_watch_logs_test.go:348 No test covers ResourceExhausted, the one code where the SDK classification (ErrorUnavailable)... test-quality
Minor openshell/v1/sandbox_watch_logs_test.go:414 The backoff cap (watchLogsMaxBackoff via min()) is never exercised: the backoff tests stop at... test-quality
Minor openshell/v1/sandbox_watch_logs_test.go:579 TestWatchLogs_ContextCancelClosesChannel throws away the drained events, so it cannot detect a... test-quality
Minor openshell/v1/types/errors.go:28 ErrorOutOfRange and IsOutOfRange are documented as meaning only 'a stream resume cursor is... architecture
Notable openshell/v1/sandbox_watch_logs.go:54 Need is UNCLEAR: the PR description only states that the change is ported from... goal-alignment
Notable openshell/v1/watch.go:36 The new watcher.emit/sleep/stopped helpers centralize the send-or-stop select, but the existing... architecture

All findings are posted as inline comments. The one that matters most is the Important resume-cursor issue in sandbox_watch_logs.go (lines 145-146): a high-water cursor can skip an undelivered lower-cursor event after a drop. Everything else is nonblocking polish, documentation and test coverage.

Review Details

  • Review type: initial review, full scope (no earlier review on this PR)
  • Findings posted: 14
  • Findings reviewed and not posted: 0
  • Gate outcome: FAIL (one Important finding)
  • Participating reviewers: architecture, goal-alignment, production, test-quality, gator, codex
  • Coverage: incomplete. The correctness and security reviewers ran and found nothing, but the CodeRabbit reviewer failed to run, so this review does not count as complete coverage.

Coverage limitation: required reviewer coderabbit failed, so this run cannot establish a completed review baseline.


// WatchLogs returns Unimplemented — the fake client has no log or platform
// event stream to replay, so it cannot honor the cursor resume contract.
func (c *fakeSandboxClient) WatchLogs(_ context.Context, _, _ string, _ ...v1.WatchLogsOptions) (types.WatchInterface[*types.WatchLogEvent], error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The fake WatchLogs returns Unimplemented, or Unavailable once the client is closed, but neither path has a test. The sibling GetLogs stub has both cases covered in fake/sandbox_test.go.

Why this matters: If this stub regressed to returning (nil, nil) or skipped the closedFunc() check, everything would still compile and pass. Callers built on the fake would then dereference a nil WatchInterface, or see Unimplemented where they expect Unavailable.

Suggested fix: Add TestSandbox_WatchLogs_ReturnsUnimplemented and TestSandbox_WatchLogs_ClosedReturnsUnavailable, modeled on the GetLogs pair (fake/sandbox_test.go:994-1006), asserting a nil watcher and the matching Is* predicate.

Source: test-quality agent

Comment thread openshell/v1/sandbox.go
WaitReady(ctx context.Context, workspace, name string, opts ...WaitOptions) (*Sandbox, error)
WaitStopped(ctx context.Context, workspace, name string, opts ...WaitOptions) (*Sandbox, error)
Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error)
WatchLogs(ctx context.Context, workspace, name string, opts ...WatchLogsOptions) (WatchInterface[*WatchLogEvent], error)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: WatchLogs goes straight into the exported SandboxInterface. Further down this file, SandboxTemplateCreateInterface and SandboxSuspendResumeInterface were added as separate optional interfaces whose doc comments say they are additive "without widening SandboxInterface", and the README promises the original interface stays unchanged for existing mocks and wrappers.

Why this matters: Any downstream type that implements v1.SandboxInterface (hand-written mocks, wrappers, decorators in operator test suites) stops compiling on upgrade. This PR already had to patch three in-repo stubs (stubSandboxResolver, mockSandboxResolver, flippableResolver), and the description doesn't mention the break.

Suggested fix: Either move WatchLogs into an optional interface such as SandboxWatchLogsInterface, implemented by sandboxClient and the fake, or call out the source-incompatible change in the PR description and the README migration section.

Source: goal-alignment agent

// testHookWatchLogsSleep, when set by a test, observes each requested backoff
// delay. It never waits: watcher.sleep performs the only real, cancellable
// wait. It is nil in production.
var testHookWatchLogsSleep func(time.Duration)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: testHookWatchLogsSleep is a package-level mutable function variable in production code, used as a test seam. Nothing else under openshell/ does this.

Why this matters: retryWatchLogs reads the global from every watcher goroutine, while the backoff tests assign and restore it without synchronization. That rules out t.Parallel for the whole package and ships a test concern in the library.

Suggested fix: Make the backoff observer (or a sleep func) a field on sandboxClient, or an unexported parameter of runWatchLogs/retryWatchLogs, so each test sets it on its own client and no global state is shared.

Source: architecture agent

//
// The returned watcher's channel closes when the server ends the stream, when
// the context is cancelled, or after Stop. Call Stop to release the stream.
func (s *sandboxClient) WatchLogs(ctx context.Context, workspace, name string, opts ...WatchLogsOptions) (WatchInterface[*WatchLogEvent], error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notable: The PR description says this is ported from NVIDIA/OpenShell#3662 but doesn't say who needs resumable log and event watching in this SDK. The fit itself looks fine (the core proto already has resume_after_cursor with OUT_OF_RANGE semantics, and the README lists watch primitives and streaming logs), so this is about the record, not the change.

Why this matters: Without a use case it's hard to judge whether the reconnect loop and backoff are worth their complexity compared with GetLogs or the existing Watch.

Suggested fix: Copy one concrete example from the upstream PR into the description, for instance which consumer needs loss-aware resume across reconnects and what breaks today without it.

Source: goal-alignment agent


reconnect := false
for {
ev, recvErr := stream.Recv()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The transparent reconnect relies on stream.Recv() returning Unavailable when the connection dies, but NewConnection in internal/grpc/conn.go sets no gRPC keepalive. On a half-open connection Recv just blocks.

Why this matters: A quiet sandbox (only FollowLogs, nothing logged for minutes) behind a load balancer or NAT with an idle timeout, or a gateway node lost without a FIN/RST, leaves the TCP connection half-open. No error reaches the HTTP/2 transport, so the resume path at lines 119-128 never runs and the caller sees a silent channel until its context ends. That's exactly the long-lived stream case the "reconnects transparently" contract is meant for.

Suggested fix: Configure client keepalive in NewConnection, for example grpc.WithKeepaliveParams(keepalive.ClientParameters{Time: 30 * time.Second, Timeout: 10 * time.Second, PermitWithoutStream: false}) aligned with the gateway's enforcement policy, or expose it as a client option, so a dead transport surfaces as Unavailable and triggers the resume loop.

Source: production agent

assert.Equal(t, 1, attempts, "OUT_OF_RANGE must not be retried")
}

func TestWatchLogs_NonRetryableErrorIsTerminal(t *testing.T) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Nothing tests ResourceExhausted, the one code where the SDK classification (ErrorUnavailable) and the retry decision (GRPCCode == codes.Unavailable) disagree. TestWatchLogs_NonRetryableErrorIsTerminal only uses PermissionDenied.

Why this matters: The GRPCCode check in isRetryableWatchLogsError is deliberate and correct, but a plausible refactor to types.IsUnavailable(err) would make a rate-limited gateway get redialed forever while this test keeps passing.

Suggested fix: Make the test table-driven over non-retryable codes and include codes.ResourceExhausted, asserting one EventError with GRPCCode == ResourceExhausted and len(mock.watchRequests) == 1.

Source: test-quality agent

assert.Equal(t, watchLogsInitialBackoff, recordedBackoffs[1])
}

func TestWatchLogs_BackoffGrowsWhenOnlySnapshotsArrive(t *testing.T) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The 2s backoff cap (min(*backoff*2, watchLogsMaxBackoff)) is never reached in tests. TestWatchLogs_BackoffGrowsWhenOnlySnapshotsArrive records 100/200/400ms, and StopInterruptsBackoff stops at the first delay of 800ms or more.

Why this matters: Dropping or mis-setting the cap would pass every test, and a gateway that stays down would then back off without bound (1.6s, 3.2s, 6.4s and so on). The tests can't cheaply reach the cap because the hook only observes and w.sleep waits in real time.

Suggested fix: Let the test seam replace the wait (or inject a sleep func on the watcher) so a test can drive about six retries instantly and assert that the sequence ends at watchLogsMaxBackoff and stays there.

Source: test-quality agent

defer w.Stop()

cancel()
drainWatchLogs(t, w)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: TestWatchLogs_ContextCancelClosesChannel discards what drainWatchLogs returns, so it can't catch a spurious EventError on cancellation. TestWatchLogs_StopClosesChannel has the same gap.

Why this matters: After cancel(), stream.Recv returns a Canceled status, and only the ctx.Err() != nil guard in retryWatchLogs keeps a terminal EventError from being emitted. If that guard went away, this test would still pass.

Suggested fix: Check the drained events, for example events := drainWatchLogs(t, w); for _, ev := range events { assert.NotEqual(t, EventError, ev.Type) }, or simply assert.Empty(t, events) since the mock sends nothing in this test.

Source: test-quality agent

ErrorUnimplemented
ErrorConflict
ErrorUnauthenticated
// ErrorOutOfRange reports unrecoverable loss on a resumable stream: the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: ErrorOutOfRange and IsOutOfRange are documented as meaning a stream resume cursor is gone, but converter.grpcToSDK now maps gRPC OUT_OF_RANGE to it for every RPC (files, exec, policies and so on).

Why this matters: Any other RPC that returns OUT_OF_RANGE (the standard code for reads or seeks past a valid range) will surface with a documented meaning of "events after a resume cursor are lost". Callers that matched IsInvalidArgument for it before will silently stop matching, and the PR doesn't call out that remap.

Suggested fix: Describe ErrorOutOfRange generically, mirroring gRPC OUT_OF_RANGE, and keep the resume-cursor meaning in the WatchLogs and WatchLogsOptions.ResumeAfterCursor docs. Also note the InvalidArgument to OutOfRange remap in docs/src/error-handling.md.

Source: architecture agent

Comment thread openshell/v1/watch.go
}
}

// emit delivers an event unless the watcher was stopped or the context ended

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notable: The new watcher.emit/sleep/stopped helpers centralize the send-or-stop select, but the existing Watch goroutine in sandbox_client.go (lines 440-444 and 454-462) still hand-rolls the same selects. The two already differ: emit honors ctx.Done(), the inline selects don't.

Why this matters: With two delivery patterns on the same watcher type, future fixes to delivery semantics have to be made twice.

Suggested fix: Optionally switch the existing Watch goroutine to emit/stopped in a follow-up. It touches unchanged code, so it's fine to leave it out of this PR.

Source: architecture agent

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

port PR ported from NVIDIA/OpenShell with its original authorship

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants