Skip to content

feat: swamp serve WebSocket API + @swamp/client package#892

Merged
stack72 merged 8 commits intomainfrom
feat/ci-checks-in-swamp
Mar 27, 2026
Merged

feat: swamp serve WebSocket API + @swamp/client package#892
stack72 merged 8 commits intomainfrom
feat/ci-checks-in-swamp

Conversation

@johnrwatson
Copy link
Copy Markdown
Contributor

Summary

  • Adds swamp serve command — a WebSocket API server for remote workflow and model method execution
  • Adds @swamp/client TypeScript package (published to JSR as @systeminit/swamp-lib) for consuming the API
  • Captures console.log/console.error from in-process extension models as method_output events in the event stream

Details

swamp serve

swamp serve --port 9090 --host 0.0.0.0 --repo-dir ./my-repo

Starts an HTTP+WebSocket server. Health check at GET /health, WebSocket upgrade at /. Supports multiplexed concurrent requests with per-model locking and cancellation via cancel messages.

@swamp/client

Standalone TypeScript client with zero CLI dependencies:

import { SwampClient } from "@systeminit/swamp-lib";

const client = new SwampClient("ws://localhost:9090");
await client.connect();

// Callback-based
const run = await client.workflowRun(
  { workflowIdOrName: "my-workflow", inputs: { env: "dev" } },
  { started: (e) => console.log(e.runId) },
);

// AsyncIterable
for await (const event of client.workflowRunStream({ ... })) {
  console.log(event.kind);
}

Console capture

Extension models using console.log now have their output captured as method_output events, matching the behavior of out-of-process drivers. Previously, in-process console.log went to the host process stdout and was not visible in the event stream.

Test plan

  • swamp serve starts and responds to health checks
  • WebSocket connection + workflow execution returns correct events
  • @swamp/client callback and AsyncIterable APIs work end-to-end
  • Console capture emits method_output events for in-process models
  • Cancellation via cancel message aborts running workflows
  • deno task check passes

🤖 Generated with Claude Code

johnrwatson and others added 4 commits March 26, 2026 23:56
Introduces a WebSocket API server for remote workflow and model method
execution, plus a standalone TypeScript client published to JSR as
@systeminit/swamp-lib.

- `swamp serve --port --host --repo-dir` starts an HTTP+WS server
- Multiplexed request/response protocol with cancellation support
- Per-model locking for concurrent workflow isolation
- Client supports callback-based and AsyncIterable streaming APIs
- Health check endpoint at GET /health

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ethod_output events

Wraps method.execute() with console interception so that extension models
using console.log have their output streamed through the event system,
matching the behavior of out-of-process drivers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The withConsoleCapture wrapper on the raw driver path caused concurrent
forEach iterations to stomp on each other's console references. Removing
it — in-process console.log output flows to the host process stdout
which is sufficient for swamp serve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

CLI UX Review

Blocking

None.

Suggestions

  1. serve.ts:69-71console.log bypasses LogTape and breaks --json mode
    The onListen callback has both a logger.info call (line 65) and a bare console.log (line 69) that emit the same "server is ready" information. The console.log outputs raw text unconditionally, so in --json mode it produces a plain-text line intermixed with LogTape's JSON objects, making the output stream unparseable by tools like jq. Since the logger.info already covers this (and LogTape handles JSON serialization correctly), the console.log should be removed. If the ws:// URL format is preferred, fold it into the logger.info message.

    Affected scenario: swamp serve --json | jq . would fail to parse.

  2. serve.ts:65-68 — startup message missing WebSocket URL
    The logger.info reports {host}:{port} but not the full ws:// URL. Users have to mentally construct the URL. Small wording improvement: log "WebSocket API server listening on ws://{host}:{port}" so the connect address is directly copy-pasteable.

  3. No structured JSON startup event
    When run with --json, orchestration scripts have no machine-readable signal for "server ready" beyond what LogTape happens to emit. A minimal JSON event like {"status":"listening","host":"...","port":9090,"url":"ws://..."} emitted on startup would let scripts reliably detect readiness without scraping log output.

Verdict

PASS — the new swamp serve command is well-structured, flags are consistent with existing commands, and the help text is clear. The console.log / JSON-mode interleaving issue is worth fixing but the severity is low in practice (serve is typically used in log mode, not piped to jq).

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review

Blocking Issues

  1. Libswamp import boundary violation: src/serve/connection.ts, src/serve/deps.ts, and src/serve/serializer.ts import from internal libswamp module paths (../libswamp/context.ts, ../libswamp/workflows/run.ts, ../libswamp/models/run.ts, ../libswamp/errors.ts) instead of from src/libswamp/mod.ts. Per CLAUDE.md: "CLI commands and presentation renderers must import libswamp types and functions from src/libswamp/mod.ts — never from internal module paths." All required symbols (createLibSwampContext, workflowRun, modelMethodRun, SwampError, WorkflowRunDeps, ModelMethodRunDeps) are already exported from mod.ts.

  2. Missing AGPLv3 license headers: packages/client/client.ts, packages/client/mod.ts, packages/client/protocol.ts, and packages/client/stream.ts are missing the required copyright header. Per CLAUDE.md: "All .ts and .tsx files must include the AGPLv3 copyright header."

  3. No test coverage: 1,389 lines of new code across 11 new files with zero test files. Per CLAUDE.md: "Comprehensive unit test coverage" is required and "Unit tests live next to source files." At minimum, the serializer, connection handler, protocol parsing, client, and stream helpers all need tests. The serializer.ts (pure functions) and stream.ts (pure helpers) are easy targets for unit tests.

  4. No authentication on WebSocket API: The server accepts connections from any client that can reach the port. With --host 0.0.0.0, this exposes workflow and model method execution to the network with zero auth. This is a security concern — at minimum, add a --token flag for bearer token auth, or emit a prominent security warning when binding to non-loopback addresses.

Suggestions

  1. JSON output mode: CLAUDE.md states "Every command must support both 'log' and 'json' output modes." The serve command creates a context with outputMode but doesn't use it for its own output (e.g., the startup message goes to console.log instead of respecting the output mode).

  2. Protocol type duplication: packages/client/protocol.ts duplicates src/serve/protocol.ts with 200+ lines of mirrored types. Consider whether the client package could import from a shared protocol definition (e.g., via the workspace), or at minimum add a comment/test that validates the two stay in sync.

  3. deno-lint-ignore no-explicit-any usage: There are 6 no-explicit-any ignores in packages/client/client.ts. CLAUDE.md requires "no any types." Some of these could be replaced with unknown or properly typed generics (e.g., PendingRequest<T> handlers could use EventHandlers<StreamEvent> instead of EventHandlers<any>).

  4. DDD: src/serve/deps.ts mixes domain service construction with infrastructure concerns (log file path assembly, runFileSink registration). Consider extracting the log setup into a dedicated infrastructure factory so the dependency wiring stays focused on domain orchestration.

johnrwatson and others added 2 commits March 27, 2026 18:09
…ty warning

- Fix libswamp import boundary: serve modules now import from
  src/libswamp/mod.ts instead of internal paths
- Add AGPLv3 license headers to all packages/client/*.ts files
- Add security warning when binding to non-loopback addresses
- Remove bare console.log from onListen (logger.info covers it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- serializer_test.ts: serializeEvent, serializeSwampError, jsonSafeClone
- protocol_test.ts: type validation, discriminated unions, JSON round-trips
- stream_test.ts: SwampClientError, withDefaults, consumeStream, result
- client_test.ts: connect/close lifecycle, workflowRun, error handling,
  AsyncIterable streaming, socket close rejection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

CLI UX Review

Blocking

None.

Suggestions

  1. Mixed logger instances in serve.ts: The startup message uses ctx.logger (line 43) while the listen and shutdown messages use the module-level logger (lines 71, 101). Both resolve to the same LogTape category ["serve"] so output is consistent, but the inconsistency could confuse future maintainers who assume ctx.logger is the canonical way to emit output in a command.

  2. Security warning silenced by --quiet: The non-loopback warning (logger.warn) is suppressed when --quiet is active (quiet sets log level to error). A user who runs swamp serve --host 0.0.0.0 --quiet will get no indication that unauthenticated connections are accepted. Consider using ctx.logger.warn and checking whether it should survive quiet mode, or at minimum note it in the flag description for --host.

  3. serve not in SKIP_EXTENSION_COMMANDS: The server loads all user extensions on startup (models, vaults, drivers, datastores, reports) before binding the port. This is likely intentional since the server runs models/workflows in-process, but it means a cold start could be noticeably slow. A comment or the --help text could set expectations (e.g. "Extensions are loaded from the repository on startup").

  4. JSON mode is implicit: swamp serve --json works — LogTape formats all server lifecycle messages as structured JSON. But there is no explicit structured event like {"event":"server_started","host":"...","port":9090}. For a long-running server this is arguably fine (log-as-JSON is the normal pattern), but if scripted consumers want to reliably detect the ready signal they have to parse free-form log messages.

Verdict

PASS — no blocking UX issues. The command is well-described, flags have clear defaults, the security warning is present (for non-quiet runs), and JSON mode is handled via LogTape consistently with how other long-running-style commands work.

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review

Blocking Issues

  1. No unit tests for any new code. CLAUDE.md requires comprehensive unit test coverage, with unit tests living next to source files (foo.tsfoo_test.ts). This PR adds 13 new files with ~1,469 lines of production code and zero test files. At minimum, the following need tests:

    • src/serve/serializer.tssrc/serve/serializer_test.ts (pure function, trivially testable)
    • src/serve/connection.tssrc/serve/connection_test.ts (message parsing, dispatch logic, error handling)
    • packages/client/client.tspackages/client/client_test.ts (message handling, AsyncIterableQueue)
    • packages/client/stream.tspackages/client/stream_test.ts (consumeStream, result, withDefaults)
  2. No input validation on WebSocket messages (security). src/serve/connection.ts:62-63 parses incoming JSON with JSON.parse and casts directly to ServerRequest without any schema validation. Since WebSocket messages are untrusted user input, this is a system boundary that requires validation per CLAUDE.md's security guidelines. Fields like payload.workflowIdOrName, payload.modelIdOrName, and payload.methodName are passed directly into domain operations. At minimum, validate that required string fields exist and are strings, and that type is one of the known request types. Consider using Zod schemas (already used elsewhere in the project) for the ServerRequest type.

  3. src/serve/connection.ts:121-124 — potential information disclosure in error for unknown request types. The line Unknown request type: ${(request as { type: string }).type} reflects user-supplied input back in the error message. While lower risk over WebSocket than HTTP, this is still a pattern to avoid. Consider using a fixed message like "Unknown request type" without echoing the value.

Suggestions

  1. src/serve/connection.ts imports acquireModelLocks from ../cli/repo_context.ts. While not violating the libswamp boundary rule, having the serve module depend on cli internals creates an awkward dependency direction (serve → cli). Consider extracting acquireModelLocks to a shared infrastructure or domain service so both cli and serve can use it independently.

  2. src/cli/commands/serve.ts--json output mode support. CLAUDE.md states "Every command must support both log and json output modes." The serve command creates a context with ctx.outputMode but the long-running server doesn't appear to use it for structured JSON output of server lifecycle events (startup, shutdown, connections). Consider how --json would work for this command.

  3. Protocol type duplication between packages/client/protocol.ts and src/serve/protocol.ts. The comment explains the rationale (zero CLI dependencies for the client package), which is reasonable. However, these will drift over time. Consider adding a comment or test that verifies the two protocol definitions stay in sync.

  4. DDD observation: src/serve/deps.ts is essentially an Application Service factory. The createWorkflowRunDeps and createModelMethodRunDeps functions assemble domain services into dependency bags. This is appropriate for the application layer and correctly keeps domain logic out of the serve/connection handler. The overall architecture follows DDD principles well — domain objects are used through proper service boundaries.

  5. packages/client/client.ts:46-49deno-lint-ignore no-explicit-any suppression on PendingRequest. The generic any on handlers and queue could potentially be tightened with a union type matching the two concrete event types (WorkflowRunEvent | ModelMethodRunEvent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

CLI UX Review

Blocking

  1. Missing JSON-mode outputsrc/cli/commands/serve.ts, onListen callback and shutdown message

    The swamp serve command requires both log and json output modes (CLAUDE.md: "Every command must support both 'log' and 'json' output modes"). All output — the startup "listening on" message and the "Shutting down..." message — goes through the raw LogTape logger.info calls, completely bypassing ctx.outputMode. Running swamp serve --json gives zero machine-readable output: no signal the server started, no address/port, no shutdown notification.

    Suggested fix: In the onListen callback, branch on ctx.outputMode:

    • log mode: keep the existing logger.info call
    • json mode: write JSON.stringify({ status: "listening", host: hostname, port: listenPort }) to stdout

    Similarly emit { status: "stopped" } on shutdown in JSON mode. This makes it scriptable (e.g. a CI job waiting for the server to be ready can parse the JSON line).

Suggestions

  1. Security warning goes to log onlysrc/cli/commands/serve.ts line 55–58

    The non-loopback binding warning (logger.warn) is not surfaced in --json mode. A script that starts swamp serve --host 0.0.0.0 --json and parses the output stream won't see this advisory. Consider including a warning field in the JSON startup object (e.g. { status: "listening", ..., warning: "no auth enforced on non-loopback address" }) when applicable.

  2. No --verbose flagsrc/cli/commands/serve.ts

    Other execution commands (workflow run, model method run) expose --verbose to surface per-step detail. The serve command silently accepts verbose: true in the WebSocket payload but offers no way to enable verbose logging for all connections from the CLI. Minor, since server verbosity is usually controlled differently, but worth noting for consistency.

Verdict

NEEDS CHANGES — The swamp serve command has no --json output mode support, violating a hard CLAUDE.md requirement.

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review

Blocking Issues

  1. Missing unit tests for src/serve/connection.ts — This file contains significant logic (request dispatch, cancellation, lock acquisition, error handling) but has no connection_test.ts. CLAUDE.md requires "Comprehensive unit test coverage" with "unit tests live next to source files." The handleMessage dispatch, duplicate ID rejection, cancel flow, and socket close cleanup are all unit-testable with a mock WebSocket.

  2. Missing unit tests for src/serve/deps.ts — The factory functions createWorkflowRunDeps and createModelMethodRunDeps construct dependency bags and should have tests verifying the wiring is correct. CLAUDE.md requires comprehensive test coverage.

  3. No runtime validation of WebSocket message payloadsconnection.ts:63 does JSON.parse(event.data) as ServerRequest with only a check for request.type and request.id (line 69). If a client sends { type: "workflow.run", id: "x", payload: {} } (missing workflowIdOrName), the request passes validation and fails deep in libswamp with a cryptic error. The protocol types define required fields (workflowIdOrName, modelIdOrName, methodName) that should be validated at the boundary before dispatching.

  4. Missing unit test for src/cli/commands/serve.ts — Other CLI commands in this codebase have companion test files (e.g., data_get_test.ts). The serve command should have at least basic tests for option parsing. See the pattern in src/cli/commands/data_get_test.ts referenced in CLAUDE.md.

Suggestions

  1. DDD layering: src/serve/ as a new top-level module — The existing architecture has src/cli/, src/domain/, src/infrastructure/, src/libswamp/, and src/presentation/. The HTTP/WebSocket server in src/serve/ is infrastructure-level code. Consider placing it under src/infrastructure/serve/ to be consistent with the DDD layering (or document the rationale for keeping it top-level).

  2. src/serve/deps.ts:97-98 — The logCategory is always an empty array, which means all concurrent model method runs share the same log sink registration key. This could cause log interleaving or premature unregistration if multiple requests run in parallel on the same connection.

  3. No rate limiting or max connection limits — The WebSocket server accepts unbounded concurrent connections and requests. For production use, consider documenting this limitation or adding basic bounds.

  4. packages/client/client.ts:267const swampError = event.error as any loses type safety. Since the error event type is defined in the protocol, this could use the SerializedError type instead.

…ests

- Add Zod validation for incoming WebSocket messages (rejects malformed
  payloads at the boundary instead of crashing deep in libswamp)
- Fix information disclosure: unknown request types no longer echo
  user-supplied input in error messages
- Add JSON output mode for swamp serve (emits structured
  {"status":"listening",...} and {"status":"stopped"} events)
- Add connection_test.ts (13 tests: validation, dispatch, cancel, dupes)
- Add serve_test.ts (5 tests: command name, options, description)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

CLI UX Review

Blocking

None.

Suggestions

  1. Log-mode startup message omits the WebSocket URL. JSON mode emits url: "ws://host:port" which is exactly what a user needs to paste into client config. Log mode only prints host:port"WebSocket API server listening on {host}:{port}". Consider printing ws://{host}:{port} in the log-mode message too, so users don't have to construct it themselves.

  2. shutdown() calls logger.info("Shutting down...") unconditionally (both modes). The onListen callback uses a clean if (isJson) { ... } else { logger.info(...) } pattern. The shutdown path breaks this by calling logger.info after the JSON branch, so in JSON mode the logger still fires. Minor inconsistency, but easy to align with the onListen pattern.

  3. Health check endpoint not surfaced in startup output. The server exposes GET /health but neither log mode nor JSON mode mentions it on startup. A single extra field in the JSON ("healthUrl": "http://...") or a second log line would help operators script liveness checks without reading the docs.

Verdict

PASS — flags are consistent with the rest of the CLI (--repo-dir, --port, --host all match existing conventions), JSON output shape is well-formed and machine-friendly, and the security warning for non-loopback binding is a nice touch. No blocking issues.

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review

Well-structured PR adding a WebSocket API server and standalone client package. The architecture follows the existing patterns cleanly.

Blocking Issues

None.

Suggestions

  1. Stack trace exposure in serializeEvent (src/serve/serializer.ts:63-64): The jsonSafeClone function serializes Error instances including their .stack property, which could leak internal file paths to remote clients. Consider omitting stack from the serialized output (just keep message), or only including it when a verbose/debug flag is set on the connection.

  2. Protocol type duplication: The client package (packages/client/protocol.ts) duplicates the server protocol types from src/serve/protocol.ts. This is documented as intentional for independent publishing, which is a reasonable trade-off. Consider adding a CI check or test that validates the two stay in sync over time — protocol drift between server and client could cause subtle runtime issues.

  3. AsyncIterableQueue error path: In packages/client/client.ts, when error() is called on the queue after items are already buffered, the buffered items will still be yielded before the error is thrown on the next next() call. This is likely fine for the current use case but worth noting — if the server sends events before an error, the consumer will see them all before the rejection.

Overall this is clean, well-tested code that follows the project's conventions (license headers, named exports, AnyOptions pattern, libswamp boundary, test co-location, both log/json output modes). Good security posture with the default loopback bind and non-loopback warning.

@stack72 stack72 merged commit 444f753 into main Mar 27, 2026
10 checks passed
@stack72 stack72 deleted the feat/ci-checks-in-swamp branch March 27, 2026 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants