server

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

daemon.go implements the process lifecycle behind `chatwright server start`/`stop`: writing and reading a PID file, detecting a stale one (the recorded process no longer exists), re-executing this same binary as a detached `server serve` child, and terminating it again. The platform- specific primitives it needs — "is this pid still alive", "ask it to terminate", and "what SysProcAttr detaches a child from this process's session" — live in daemon_unix.go/daemon_windows.go behind three small functions, so everything else here is pure, portable Go and directly testable without actually forking a long-lived daemon.

datastate.go implements POST /datastate/query: the database side-effect verification seam. It evaluates a request against chatwright.dev/runtime/datastate's own Runner/Expectation machinery (spec/features/chatwright/deterministic-testing/data-state-assertions in the chatwright/chatwright repository) — this file never reimplements row matching or evidence bounding itself.

What is genuinely wired: the full datastate.Runner/Assertion/Expectation pipeline (canonical ordering, field exclusion, redaction, bounded preview) driving a small JSON expectation DSL that maps 1:1 onto datastate's own combinators (NonEmpty, Empty, ExactRowCount, MinRowCount, MaxRowCount, RowContains, ExactRows, and All for conjunction).

What is stubbed: real DTQL parsing/execution against a live dalgo/DataTug database. datastate.Executor is the seam DALgo's real dtql/dal.DB implementation is meant to satisfy (see chatwright.dev/runtime/datastate's own package doc comment); that integration does not exist yet in this CLI. FixtureStore is a clearly-labeled, in-memory stand-in: it treats a Query's DTQL text as an opaque, exact-match lookup key into rows an operator pre-loads from a JSON file (see LoadFixturesFile) — it does not parse or interpret DTQL syntax at all. When no fixtures file is configured, POST /datastate/query always answers verdict "unsupported": this server never claims a query passed against a database it never actually reached.

models.go serves GET /v1/models by proxying the upstream's model list (OpenAI-compatible: Ollama and LM Studio both expose /v1/models), so the Studio UI can offer a dropdown of the models actually available on this machine instead of a free-text field. Like the chat-completions proxy it drops the browser's Origin/fetch-metadata request headers before the server-to-server call, so the upstream's own CORS never rejects it.

proxy.go is the OpenAI-compatible reverse proxy for POST /v1/chat/completions: it forwards the caller's request body verbatim, server-to-server, to Config.UpstreamBaseURL (a local Ollama/LM Studio/any OpenAI-compatible server) and relays the response back faithfully — preserving status code and body — while recording a CallMetric for GET /metrics.

A non-streaming ("stream" absent or false) call is buffered: the full response is read so its JSON body's model/usage.prompt_tokens/ usage.completion_tokens can be extracted for the metric before it is written back to the caller. A streaming call ("stream": true) is relayed chunk-by-chunk with a flush after every chunk, exactly as the upstream sends it — Server-Sent-Events deltas do not carry cumulative token counts by default, so a streamed call's CallMetric always has zero prompt/completion tokens (Streamed is set to true precisely so a GET /metrics consumer can tell "zero because streaming" apart from "zero because unknown").

Package server implements the chatwright server companion daemon: a host-side HTTP proxy that lets the browser Studio (served from https://chatwright.dev) reach local AI model servers and verify database assertions — things a sandboxed HTTPS page cannot do directly (mixed content blocks a page loaded over HTTPS from calling http://localhost, and a local model server rarely sends CORS headers of its own).

This package holds every byte of the server's behavior — request routing, the reverse proxy, the metrics ring buffer, the datastate evaluation seam and the daemon lifecycle primitives. cmd/chatwright's server.go stays a thin flag-parsing front end, per this repository's own AGENTS.md ("the CLI is deliberately thin ... engine or wire logic never lives here").

ui.go is the static-file seam that lets this same server also serve a built Studio web UI locally (--ui-dir), enabling an offline/local-first mode later. It does not fetch, bundle, or know anything about how that UI is built or packaged — it only serves whatever directory the operator points it at, with an SPA fallback to index.html for any unknown non-API path, since a client-side-routed SPA's own routes (e.g. "/runs/42") are not real files on disk.

API routes are registered on the same http.ServeMux as their own exact-match patterns ("/health", "/metrics", "/v1/chat/completions", "/datastate/query"), which always win over the "/" subtree pattern the UI handler is mounted on — see buildMux. This is what "never collide with UI routes" means in practice: there is no path-prefix reservation to maintain, Go's own ServeMux longest-match rule does it.

ui_offline.go implements `chatwright server serve --ui`'s download/cache/verify pipeline for the Studio web UI, so the whole tester can run with no network after the first successful fetch. It hands its result to ui.go's existing --ui-dir handler, unchanged: this file's only job is getting a verified UI onto local disk and returning that directory, never serving HTTP itself.

The packaging contract this file consumes is produced by the Studio release process, not by this repository, and must match exactly:

  • studio-ui.zip — a zip whose root contains the built SPA (index.html at the root, assets alongside).
  • studio-ui.manifest.json — {"version": "<string>", "sha256": "<lowercase hex sha256 of studio-ui.zip>", "uiContract": <int>}. uiContract is the UI<->server compatibility integer this file gates on.
  • Both are published to stable URLs under one release base, by default the Studio repository's GitHub "latest release" alias (DefaultUIBaseURL); --ui-url overrides the base for self-hosting or tests.

The pipeline, per ResolveOfflineUI:

  1. GET {base}/studio-ui.manifest.json.
  2. Compatibility gate: manifest.uiContract must equal SupportedUIContract. A mismatch is refused outright — this CLI build never serves a UI it cannot vouch for, rather than degrading silently ("fidelity is declared").
  3. Cache path is {cacheDir}/{manifest.version}/. If that directory already holds a ".sha256" marker matching manifest.sha256, the cache is used as-is — no re-download.
  4. Otherwise: GET {base}/studio-ui.zip, verify its sha256 against the manifest (reject on mismatch, before extracting anything), extract it into the cache path with a zip-slip guard (any entry whose cleaned destination would land outside the cache directory is rejected, before any file is written) and symlink entries skipped outright, then write the ".sha256" marker on success.
  5. If the manifest GET itself fails (no network) but a previously extracted version is already cached, that cached version is served with a logged note instead of failing — this is what makes offline re-runs work. With no cache to fall back to, the fetch error is returned, wrapped with enough context to act on.

Index

Constants

View Source
const DefaultAddr = "127.0.0.1:4319"

DefaultAddr is the fixed, discoverable default listen address for `chatwright server serve`/`start`. It is deliberately not an ephemeral port: the Studio web app needs a stable address to probe via GET /health without any prior handshake. 4319 was chosen to avoid the obvious collisions — common dev-server ports (3000, 5173, 8080, ...) and Ollama's own default of 11434 (the server's own default upstream, so the two must never share a port) — while still being easy to recognize and grep for in logs. Override with --addr or CHATWRIGHT_SERVER_ADDR.

View Source
const DefaultUIBaseURL = "https://github.com/chatwright/studio/releases/latest/download/"

DefaultUIBaseURL is the stable download location for the Studio web UI: the studio repository's own GitHub Releases "latest" alias. Override via OfflineUIOptions.BaseURL (wired to --ui-url) to self-host a mirror or point tests at an httptest.Server.

View Source
const DefaultUpstreamBaseURL = "http://localhost:11434/v1"

DefaultUpstreamBaseURL is the OpenAI-compatible backend the chat-completions proxy forwards to when the operator does not configure one: a local Ollama server's own OpenAI-compatible surface. Override with --upstream or CHATWRIGHT_SERVER_UPSTREAM.

View Source
const SupportedUIContract = 1

SupportedUIContract is the UI<->server compatibility integer this CLI build understands. ResolveOfflineUI refuses to serve any Studio UI release whose manifest declares a different uiContract: a testing tool that quietly drifts from what it claims to test is worse than one that refuses to run.

Variables

View Source
var Capabilities = []string{"ai-proxy", "datastate"}

Capabilities is the fixed capability roster GET /health reports for Studio feature-detection. Adding a capability here is a deliberate, reviewed contract change — it is never inferred from which handlers happen to be registered.

View Source
var ErrAlreadyRunning = errors.New("chatwright server: already running")

ErrAlreadyRunning is returned by Start when the PID file names a process that is still alive.

View Source
var ErrNotRunning = errors.New("chatwright server: not running")

ErrNotRunning is returned by Stop when there is no PID file, or the PID file names a process that is no longer alive (a stale PID file, which Stop cleans up before returning this error).

Functions

func DefaultUICacheDir

func DefaultUICacheDir() string

DefaultUICacheDir is where ResolveOfflineUI caches downloaded Studio UI builds, keyed by version (DefaultUICacheDir()/<version>/). Override via OfflineUIOptions.CacheDir.

func IsProcessRunning

func IsProcessRunning(pid int) bool

IsProcessRunning reports whether pid names a live process. It is exported so `chatwright server` reports accurate status without duplicating this package's platform-specific logic.

func ReadPIDFile

func ReadPIDFile(path string) (int, error)

ReadPIDFile reads and parses the PID previously written by WritePIDFile. A missing file is reported via the underlying os.IsNotExist-satisfying error, unwrapped, so callers can distinguish "no PID file" from "PID file exists but is corrupt."

func RemovePIDFile

func RemovePIDFile(path string) error

RemovePIDFile removes path, treating an already-missing file as success.

func ResolveOfflineUI

func ResolveOfflineUI(ctx context.Context, opts OfflineUIOptions) (string, error)

ResolveOfflineUI runs the download/cache/verify pipeline documented in this file's package doc comment and returns the local directory holding the resolved Studio UI's static files — ready to pass straight to Config.UIDir. It never opens an HTTP listener itself.

func Start

func Start(opts StartOptions) (pid int, err error)

Start launches a detached child per StartOptions and records its PID. A PID file naming a still-live process is treated as ErrAlreadyRunning without touching anything; a PID file naming a dead process (stale) is removed and Start proceeds normally — this is the "stale PID file detection" the daemon lifecycle needs, factored out here so it is exercised by tests without any real daemonizing.

func Stop

func Stop(pidPath string, wait time.Duration) error

Stop reads pidPath, sends the platform's termination signal to the process it names, waits up to wait for it to exit, and removes pidPath. It returns ErrNotRunning (after cleaning up a stale PID file, if that is what was found) when there was nothing to stop. wait <= 0 uses defaultStopWait.

func WritePIDFile

func WritePIDFile(path string, pid int) error

WritePIDFile writes pid to path as decimal text plus a trailing newline.

Types

type CallMetric

type CallMetric struct {
	Timestamp        time.Time `json:"timestamp"`
	Model            string    `json:"model,omitempty"`
	LatencyMS        int64     `json:"latencyMs"`
	PromptTokens     int       `json:"promptTokens,omitempty"`
	CompletionTokens int       `json:"completionTokens,omitempty"`
	// ResponseFormat is the request's response_format.type ("json_object",
	// "json_schema", "text", ...) when the request body declared one,
	// otherwise empty.
	ResponseFormat string `json:"responseFormat,omitempty"`
	// Streamed is true when the request declared "stream": true — the
	// proxy relays these byte-for-byte and cannot recover token counts
	// from an SSE body, so PromptTokens/CompletionTokens are always 0 for
	// a streamed call.
	Streamed bool `json:"streamed"`
	// Status is the HTTP status the upstream backend returned, or 0 when
	// the call never reached it (a network/dial failure).
	Status int `json:"status"`
	// Err is set when the call failed before a status was available.
	Err string `json:"error,omitempty"`
}

CallMetric is one recorded /v1/chat/completions proxy call. Every field is best-effort: a proxied call that fails before or during forwarding still records what it can (Model/tokens may be empty/zero when the request or response body could not be parsed as JSON, e.g. a streaming response — see proxy.go's doc comment).

type Config

type Config struct {
	// Version is the running CLI's own version, reported verbatim by
	// GET /health.
	Version string
	// UpstreamBaseURL is the OpenAI-compatible backend the chat-completions
	// proxy forwards to. Empty uses DefaultUpstreamBaseURL.
	UpstreamBaseURL string
	// FixturesPath, when non-empty, is a JSON file loaded into a
	// FixtureStore backing POST /datastate/query. Empty means no store is
	// configured: every /datastate/query call returns an explicit
	// "unsupported" verdict — see FixtureStore's doc comment for exactly
	// what this does and does not wire.
	FixturesPath string
	// Logger receives one line per proxied chat-completion call plus
	// server lifecycle notices. A nil Logger discards all output via
	// log.New(io.Discard, ...).
	Logger *log.Logger
	// HTTPClient is the client used to call UpstreamBaseURL. A nil value
	// builds a client with proxyTimeout. Tests inject their own client to
	// point at an httptest.Server without touching a real network.
	HTTPClient *http.Client
	// MetricsCapacity bounds the in-memory metrics ring buffer. <= 0 uses
	// defaultMetricsCapacity.
	MetricsCapacity int
	// AllowedOrigins are extra CORS origins to allow, beyond the built-in
	// defaults (https://chatwright.dev and http://chatwright.localhost
	// see cors.go's defaultAllowedOrigins) and the generic local-dev-server
	// pattern match (any port on localhost/*.localhost/a loopback IP).
	// Blank entries are ignored.
	AllowedOrigins []string
	// UIDir, when non-empty, serves that directory's static files at "/"
	// with an SPA fallback to index.html for any unmatched non-API path —
	// see ui.go. Empty means no UI is served; unmatched paths get the
	// ServeMux's ordinary 404.
	UIDir string
}

Config configures a Server. Only Version is required; every other field has a documented zero-value behavior.

type FixtureStore

type FixtureStore struct {
	// contains filtered or unexported fields
}

FixtureStore is an in-memory, exact-match stand-in for a real datastate.Executor. It is NOT a DTQL engine — see this file's package doc comment. Query.DTQL text is trimmed and used as a literal lookup key into canned rows an operator pre-loads per holder.

func LoadFixturesFile

func LoadFixturesFile(path string) (*FixtureStore, error)

LoadFixturesFile reads a JSON file shaped as {"<holder>": {"<exact DTQL text>": [{...row...}, ...]}} into a FixtureStore.

func NewFixtureStore

func NewFixtureStore(data map[string]map[string][]datastate.Row) *FixtureStore

NewFixtureStore builds a FixtureStore directly from in-memory data, mainly for tests; LoadFixturesFile is the operator-facing constructor.

func (*FixtureStore) Execute

func (f *FixtureStore) Execute(_ context.Context, handle any, query datastate.Query) ([]datastate.Row, error)

Execute implements datastate.Executor: an exact, trimmed-text lookup into the holder's pre-loaded query map. A query text with no matching fixture is a query execution error (matching datastate's own "must fail explicitly for unsupported query" contract), never a silent empty result.

func (*FixtureStore) Handles

func (f *FixtureStore) Handles() datastate.Handles

Handles returns the datastate.Handles this store backs: one entry per configured holder, whose handle value is that holder's own query map — exactly what Execute expects to receive back via Runner's holder resolution.

type OfflineUIOptions

type OfflineUIOptions struct {
	// BaseURL is the release base to fetch studio-ui.manifest.json/.zip
	// from. Empty uses DefaultUIBaseURL.
	BaseURL string
	// CacheDir is the root directory extracted UI versions are cached
	// under, as CacheDir/<version>/. Empty uses DefaultUICacheDir().
	CacheDir string
	// HTTPClient issues the manifest/zip GETs. A nil value builds a client
	// with uiDownloadTimeout. Tests inject their own client pointed at an
	// httptest.Server without touching a real network.
	HTTPClient *http.Client
	// Logger receives progress and fallback notices (download start, cache
	// hit, offline fallback). A nil Logger discards all output.
	Logger *log.Logger
}

OfflineUIOptions configures ResolveOfflineUI.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the chatwright server companion daemon's HTTP surface. Build one with New and either call Handler to embed it (tests do this via httptest.NewServer) or ListenAndServe/Serve to run it standalone.

func New

func New(cfg Config) (*Server, error)

New builds a Server from cfg. It never opens a network listener itself — see Serve/ListenAndServe for that.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler returns the Server's full HTTP handler (routing + CORS/PNA headers on every response), suitable for embedding in an httptest.Server or any other http.Server.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(ctx context.Context, addr string) error

ListenAndServe binds addr and calls Serve. It is the convenience entry point `chatwright server serve` uses; Serve itself (taking a caller-owned net.Listener) is what tests use to run against an ephemeral port.

func (*Server) Serve

func (s *Server) Serve(ctx context.Context, ln net.Listener) error

Serve runs the Server on the given listener until ctx is canceled, then gracefully shuts it down (http.Server.Shutdown) and returns. A nil error means either a clean shutdown or the listener closing on its own accord (http.ErrServerClosed); any other error from either the listener or an unclean shutdown is returned.

type StartOptions

type StartOptions struct {
	// Executable is the binary to re-exec — the running binary's own path
	// (os.Executable()), so `start` always launches the same build of
	// chatwright that is running the `start` command itself.
	Executable string
	// Args are the arguments to launch Executable with — the caller's job
	// is to pass e.g. ["server", "serve", "--addr", addr, ...] so the
	// child runs `serve` in the foreground of its own detached session.
	Args []string
	// PIDFile is where the spawned child's PID is recorded.
	PIDFile string
	// LogFile receives the child's stdout and stderr.
	LogFile string
}

StartOptions configures Start.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL