diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d66a72f..873c5e90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,6 +297,8 @@ jobs: with: go-version: '1.25' - uses: bufbuild/buf-setup-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} - name: Determine diff base id: base run: | diff --git a/cmd/switchyard/doc.go b/cmd/switchyard/doc.go new file mode 100644 index 00000000..699c5348 --- /dev/null +++ b/cmd/switchyard/doc.go @@ -0,0 +1,7 @@ +// Command switchyard is the administrative CLI for a running Switchyard daemon. +// +// The CLI is intentionally a thin transport and presentation layer: it parses +// flags, calls the local daemon API, and formats output for humans or scripts. +// Domain behavior belongs in internal packages so the daemon, CLI, tests, and +// MCP tools share the same semantics. +package main diff --git a/cmd/switchyardd/doc.go b/cmd/switchyardd/doc.go new file mode 100644 index 00000000..5fdf6c6d --- /dev/null +++ b/cmd/switchyardd/doc.go @@ -0,0 +1,6 @@ +// Command switchyardd runs the Switchyard daemon. +// +// The daemon owns process startup, subsystem wiring, signal handling, and +// shutdown ordering. Long-lived behavior is delegated to internal packages such +// as config, eventstore, registry, state, carport, automation, API, and web. +package main diff --git a/docs/docs/contributing/index.md b/docs/docs/contributing/index.md index 88b5632f..be67f919 100644 --- a/docs/docs/contributing/index.md +++ b/docs/docs/contributing/index.md @@ -43,8 +43,9 @@ New or improved configuration examples go in `switchyard/examples/`. The directo 1. **Fork the relevant repository** — `switchyard`, `switchyard-driverkit`, or `switchyard-docs`, depending on what you are changing. 2. **Create a feature branch** — branch from `main` with a descriptive name (`feat/zigbee-driver`, `fix/event-replay-ordering`). 3. **Run tests** — `task test` must pass before opening a PR. See [Dev setup](dev-setup.md) for all available task targets. -4. **Open a PR with a clear description** — explain what the change does, why it is needed, and how you tested it. Link to the relevant issue. -5. **Address review feedback** — maintainers may request changes. Push follow-up commits to the same branch; do not force-push after review has started. +4. **Keep ownership clear** — use the [Repository architecture](repo-architecture.md) map when choosing where code belongs. +5. **Open a PR with a clear description** — explain what the change does, why it is needed, and how you tested it. Link to the relevant issue. +6. **Address review feedback** — maintainers may request changes. Push follow-up commits to the same branch; do not force-push after review has started. PRs will not be merged without a linked issue for non-trivial changes. diff --git a/docs/docs/contributing/repo-architecture.md b/docs/docs/contributing/repo-architecture.md new file mode 100644 index 00000000..f4766456 --- /dev/null +++ b/docs/docs/contributing/repo-architecture.md @@ -0,0 +1,130 @@ +# Repository Architecture + +This page is the contributor map for Switchyard's source tree. It complements +the user-facing architecture overview and the deeper internals notes by +answering a narrower question: where does code belong, and which packages own +which contracts? + +## Top-level layout + +| Path | Owner | Notes | +|------|-------|-------| +| `app/` | Admin UI | Vue application, generated API clients, tests, and static assets that are embedded into `switchyardd`. | +| `cmd/switchyard/` | CLI | Thin command-line adapter over daemon APIs. Domain behavior should not live here. | +| `cmd/switchyardd/` | Daemon entrypoint | Startup, shutdown, config flags, and top-level wiring only. | +| `cmd/testdriver/` | Test binary | Scenario driver used by integration tests. | +| `docs/` | Documentation | Zensical site, design history, ADRs, and agent-authored specs/plans. | +| `drivers/` | First-party drivers | Out-of-process Carport drivers shipped with this repo. | +| `examples/` | Example config | Sample Pkl configs and fragments. Runtime config does not read this directory by default. | +| `gen/` | Generated Go | Committed protobuf output. Edit `proto/`, then run `task proto`. | +| `internal/` | Daemon internals | Main domain packages and API adapters. External modules must not import these. | +| `proto/` | Wire contracts | Protobuf API and event schema definitions. Preserve field numbers and reserve removed fields. | +| `switchyard-driverkit/` | Public Go SDK | Separate Go module for driver authors. Keep dependencies and APIs suitable for third-party drivers. | +| `testdata/` | Fixtures | Golden CLI output, integration fixtures, and other stable test inputs. | + +## Runtime shape + +Switchyard is built around an event-sourced daemon: + +1. `internal/config` evaluates Pkl config into a protobuf snapshot. +2. `internal/daemon` wires that snapshot into the event store, registry, state + cache, automation engine, API listener, MCP server, and web server. +3. `internal/eventstore` serializes durable events into SQLite. +4. `internal/registry` and `internal/state` project those events into query + models. +5. `internal/carport` supervises out-of-process drivers and translates driver + messages into events. +6. `internal/api` adapts internal interfaces to Connect-RPC services. +7. `internal/mcp` calls the same daemon/API seams for agent workflows. +8. `internal/web` serves the embedded UI bundle; API traffic goes through the + Connect listener. + +The event log is the durability boundary. Registry rows, state cache entries, +activity stories, and UI views are derived state. + +## Internal package ownership + +| Package | Responsibility | Should not own | +|---------|----------------|----------------| +| `internal/activity` | Activity feed summaries and related command-catalog verbs. | Event storage or registry projection rules. | +| `internal/api` | Connect-RPC adapters, pagination, streaming helpers, and error mapping. | Business logic that should be shared with CLI/MCP. | +| `internal/api/listener` | HTTP, h2c, Unix socket, interceptors, peer credentials, and route mounting. | Service implementation logic. | +| `internal/auth` | Transport-neutral auth contracts and simple auth composition. | Credential persistence details. | +| `internal/auth/credentials` | Password hashes, API tokens, passkeys, and enrollment tokens. | Request middleware or policy decisions. | +| `internal/auth/identity` | Configured user and role projection. | Password/passkey/token verification. | +| `internal/auth/sessions` | Cookie sessions and refresh-token rotation. | WebAuthn or API token storage. | +| `internal/auth/throttle` | Failed-auth throttling buckets. | Login flow orchestration. | +| `internal/automation` | Automation runtime, triggers, condition/action compilation, and scene invocation support. | Config parsing or API response shaping. | +| `internal/carport` | Driver process lifecycle, Carport gRPC streams, command dispatch, and event ingest. | Driver-specific protocol details. | +| `internal/config` | Pkl evaluation, validation, diffs, config apply events, and live snapshot ownership. | Driver supervision or API formatting. | +| `internal/daemon` | Dependency construction and lifecycle orchestration. | Domain rules that can live in a subsystem. | +| `internal/display` | Ambient display pairing, display registry, and recommendations. | Web route rendering. | +| `internal/driver/management` | Driver settings/read-model API for the UI. | Carport process control internals. | +| `internal/editsession` | Pkl edit-session locks and conflict handling. | Pkl language-server features. | +| `internal/eventstore` | Append-only event log, projectors, subscriptions, snapshots, and replay cursors. | Domain-specific read models. | +| `internal/mcp` | MCP transport, resources, tools, and audit hooks. | Direct database access. | +| `internal/observability` | Logging, metrics, tracing shims, and recovery HTTP endpoints. | Subsystem health decisions. | +| `internal/page` | Custom page domain types, service, catalog, scaffolding, and layout storage seams. | UI rendering. | +| `internal/pkllsp` | Pkl language-server subprocess lifecycle and request translation. | Config validation. | +| `internal/policy` | Compiled authorization policy and evaluation. | Authentication. | +| `internal/registry` | SQL-backed projection of drivers, devices, entities, and subscriptions. | Event append ownership. | +| `internal/replay` | Historical state and causation-chain queries. | Live state cache mutation. | +| `internal/script` | Named Starlark script runtime and test execution. | Automation scheduling. | +| `internal/starlark` | Sandboxed Starlark VM, builtins, module loading, and limits. | Script catalog or config discovery. | +| `internal/state` | Copy-on-write live entity state cache. | Durable storage. | +| `internal/storage` | SQLite open, PRAGMAs, lockfile, and migrations. | Event schema semantics. | +| `internal/web` | Embedded SPA and widget asset serving. | Connect-RPC API handling. | +| `internal/widgetpack` | Widget-pack install and metadata pipeline. | Page layout rendering. | + +## Dependency direction + +Keep dependencies pointing inward toward stable domain seams: + +- `cmd/*` imports `internal/cli` or top-level wiring, not subsystem internals + opportunistically. +- `internal/api` depends on small interfaces from `deps.go`; daemon adapters + satisfy those interfaces. +- `internal/mcp` should call daemon/API seams rather than reaching into + eventstore, registry, or config storage. +- Driver implementations under `drivers/` use `switchyard-driverkit`; they + should not import daemon internals. +- `switchyard-driverkit/` can import generated protocol types, but not + `internal/`. + +When a package starts importing too many sibling internals, add a small +interface at the call site or move the behavior to the package that owns the +data invariant. + +## Comment and doc policy + +Every Go package should have a package comment. Use `doc.go` when the package +needs more than one sentence or when the first source file would otherwise +start with implementation details. + +Exported comments should describe the contract, not restate the identifier. +Good comments answer at least one of: + +- what invariant the type or function owns +- what caller is responsible for +- what error or concurrency behavior callers can rely on +- why the symbol is exported inside `internal/` + +Avoid comments that narrate obvious control flow. Inline comments are for +surprising constraints, ordering requirements, concurrency fences, security +boundaries, or dependency quirks. + +## Generated and derived files + +- Protobuf schemas live in `proto/`; generated Go lives in `gen/`. +- `gen/` is committed. Do not hand-edit it. +- The web bundle under `dist/` and `internal/web/dist` is derived by app build + tasks and should not be treated as source. +- Pkl-generated layout fragments belong in config or examples, not root-level + directories. + +## Adding new code + +Before adding a top-level directory, update `AGENTS.md` and this page. Before +adding a new internal package, check whether an existing package already owns +the invariant. New packages should make ownership sharper; they should not be a +holding area for miscellaneous helpers. diff --git a/docs/zensical.toml b/docs/zensical.toml index 6f8e027b..05e03f14 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -126,6 +126,7 @@ nav = [ { "Contributing" = [ { "Overview" = "contributing/index.md" }, { "Dev setup" = "contributing/dev-setup.md" }, + { "Repository architecture" = "contributing/repo-architecture.md" }, { "Architecture internals" = "contributing/architecture-internals.md" }, ]}, ] diff --git a/internal/api/deps.go b/internal/api/deps.go index 12ba27d4..a0e781d7 100644 --- a/internal/api/deps.go +++ b/internal/api/deps.go @@ -12,6 +12,7 @@ import ( "github.com/fdatoo/switchyard/internal/auth" ) +// VersionInfo is immutable build and schema metadata exposed by SystemService. type VersionInfo struct { BinaryVersion string GitCommit string @@ -19,12 +20,14 @@ type VersionInfo struct { SchemaVersion string } +// SubsystemHealth is one component's contribution to daemon health. type SubsystemHealth struct { Name string OK bool Detail string } +// MCPConfig contains runtime limits used by MCP tools and resources. type MCPConfig struct { EvalResultMaxBytes uint32 ReadFileMaxBytes uint32 @@ -41,6 +44,7 @@ type EventStoreStats struct { SnapshotCount uint32 } +// SystemBackend is the daemon-facing dependency set for SystemService. type SystemBackend interface { Version() VersionInfo Health(ctx context.Context) (ok bool, summary string, sub []SubsystemHealth) @@ -58,28 +62,33 @@ type SystemBackend interface { // --- Area & Zone --- +// Area is a configured physical or logical area. type Area struct { ID string DisplayName string ParentID string } +// Zone groups areas for navigation, policy, and automation targeting. type Zone struct { ID string DisplayName string AreaIDs []string } +// PageReq is the internal pagination request shared by API adapters. type PageReq struct { Size uint32 Cursor Cursor } +// AreaReader reads configured areas for API handlers. type AreaReader interface { ListAreas(ctx context.Context, page PageReq) ([]Area, Cursor, error) GetArea(ctx context.Context, id string) (Area, error) } +// ZoneReader reads configured zones for API handlers. type ZoneReader interface { ListZones(ctx context.Context, page PageReq) ([]Zone, Cursor, error) GetZone(ctx context.Context, id string) (Zone, error) @@ -87,6 +96,7 @@ type ZoneReader interface { // --- Device --- +// Device is the registry view of a discovered or configured device. type Device struct { ID string FriendlyName string @@ -95,6 +105,7 @@ type Device struct { EntityIDs []string } +// DeviceReader reads registry devices for API handlers. type DeviceReader interface { ListDevices(ctx context.Context, areaID string, page PageReq) ([]Device, Cursor, error) GetDevice(ctx context.Context, id string) (Device, error) @@ -110,12 +121,14 @@ type DeviceWriter interface { // --- Entity --- +// Entity is the API-layer projection of a controllable or observable entity. type Entity struct { ID, Type, DeviceID, AreaID, ZoneID, FriendlyName string State *entityv1.Attributes Capabilities *entityv1.Attributes } +// EntitySelector narrows entity reads and subscriptions. type EntitySelector struct { EntityIDs []string DeviceIDs []string @@ -124,6 +137,7 @@ type EntitySelector struct { Classes []string } +// EntityReader reads entities from the live registry and state cache. type EntityReader interface { ListEntities(ctx context.Context, sel EntitySelector, page PageReq) ([]Entity, Cursor, error) GetEntity(ctx context.Context, id string) (Entity, error) @@ -140,6 +154,7 @@ type CapabilityCallResult struct { ErrorMessage string } +// CapabilityCaller dispatches entity capability calls to the owning driver. type CapabilityCaller interface { // Call dispatches the capability invocation through the carport supervisor; // blocks until the driver acks or ctx is cancelled. The returned error is @@ -157,6 +172,7 @@ type EntityStreamSource interface { Subscribe(ctx context.Context, sel EntitySelector, fromCursor uint64) (<-chan EntityChange, func(), error) } +// EntityChange is one cursor-addressed update from an entity subscription. type EntityChange struct { EntityID string Cursor uint64 @@ -166,17 +182,20 @@ type EntityChange struct { // --- Driver --- +// Driver describes an available driver implementation. type Driver struct { Name, Version, Description string EntityClasses []string } +// DriverInstance is the runtime status of one configured driver process. type DriverInstance struct { ID, DriverName, Status string EntityCount uint32 LastHandshakeUnixMs int64 } +// DriverControl reads and mutates driver runtime state. type DriverControl interface { ListDrivers(ctx context.Context, page PageReq) ([]Driver, Cursor, error) ListInstances(ctx context.Context, page PageReq) ([]DriverInstance, Cursor, error) @@ -186,6 +205,7 @@ type DriverControl interface { // --- Event --- +// Event is the API-layer view of one eventstore row. type Event struct { Cursor uint64 At time.Time @@ -197,6 +217,7 @@ type Event struct { Payload *eventv1.Payload } +// EventFilter narrows event queries and tail subscriptions. type EventFilter struct { Kinds []string EntityPrefix string @@ -207,6 +228,7 @@ type EventFilter struct { ToTime time.Time } +// EventSource queries and subscribes to the event log. type EventSource interface { Query(ctx context.Context, filter EventFilter, page PageReq) ([]Event, Cursor, error) Subscribe(ctx context.Context, filter EventFilter) (<-chan Event, func(), error) @@ -214,6 +236,7 @@ type EventSource interface { // --- Config --- +// ConfigDiff summarizes a config validation or apply delta. type ConfigDiff struct { DriverAdded, DriverRemoved, DriverChanged int32 EntitiesAdded, EntitiesRemoved int32 @@ -221,6 +244,7 @@ type ConfigDiff struct { Lines []string } +// ConfigApplyResult is the outcome of applying a config bundle. type ConfigApplyResult struct { Applied bool Diff ConfigDiff @@ -237,6 +261,7 @@ type ConfigChangedEvent struct { BundleHash string } +// ConfigApplier validates, applies, reloads, and streams config snapshots. type ConfigApplier interface { Validate(ctx context.Context, pklBundle []byte) (valid bool, errs []string, diff ConfigDiff, hash string, err error) Apply(ctx context.Context, pklBundle []byte, message, expectedHash string, dryRun, strict bool, actor string) (ConfigApplyResult, error) @@ -250,6 +275,7 @@ type ConfigApplier interface { // --- Automation --- +// Automation is the API-layer summary of one configured automation. type Automation struct { ID, DisplayName, Mode string Enabled bool @@ -257,6 +283,7 @@ type Automation struct { Areas []string } +// TraceEvent is one automation-run trace item. type TraceEvent struct { Cursor uint64 At time.Time @@ -267,6 +294,7 @@ type TraceEvent struct { Metadata map[string]string } +// AutomationControl reads and mutates automation runtime state. type AutomationControl interface { List(ctx context.Context, page PageReq) ([]Automation, Cursor, error) Get(ctx context.Context, id string) (Automation, error) @@ -298,20 +326,24 @@ func ErrSceneNotFound() error { return errSceneNotFoundSentinel } // --- Script --- +// Script is the API-layer summary of one invocable Starlark script. type Script struct { Name, Description string } +// ScriptRunResult is the immediate result of starting or completing a script run. type ScriptRunResult struct { RunID string Result *structpb.Value } +// StarlarkTestEvent is one streamed test result from a Starlark test file. type StarlarkTestEvent struct { Name, Outcome, Detail string At time.Time } +// ScriptRunner executes Starlark scripts and test files for API handlers. type ScriptRunner interface { List(ctx context.Context, page PageReq) ([]Script, Cursor, error) Run(ctx context.Context, name string, args map[string]any, actor string) (ScriptRunResult, error) diff --git a/internal/api/errors.go b/internal/api/errors.go index 8bada052..1505f8ad 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -15,25 +15,43 @@ import ( ) var ( - ErrEntityNotFound = errors.New("entity not found") - ErrDeviceNotFound = errors.New("device not found") - ErrAreaNotFound = errors.New("area not found") - ErrZoneNotFound = errors.New("zone not found") - ErrDriverNotFound = errors.New("driver not found") - ErrInstanceNotFound = errors.New("driver instance not found") - ErrAutomationNotFound = errors.New("automation not found") - ErrScriptNotFound = errors.New("script not found") - ErrAutomationDisabled = errors.New("automation disabled") - ErrRunNotFound = errors.New("run not found") - ErrRunAlreadyFinished = errors.New("run already finished") - ErrCapabilityUnknown = errors.New("capability unknown") - ErrDriverUnavailable = errors.New("driver unavailable") + // ErrEntityNotFound maps to Connect NotFound for entity APIs. + ErrEntityNotFound = errors.New("entity not found") + // ErrDeviceNotFound maps to Connect NotFound for device APIs. + ErrDeviceNotFound = errors.New("device not found") + // ErrAreaNotFound maps to Connect NotFound for area APIs. + ErrAreaNotFound = errors.New("area not found") + // ErrZoneNotFound maps to Connect NotFound for zone APIs. + ErrZoneNotFound = errors.New("zone not found") + // ErrDriverNotFound maps to Connect NotFound for driver APIs. + ErrDriverNotFound = errors.New("driver not found") + // ErrInstanceNotFound maps to Connect NotFound for driver-instance APIs. + ErrInstanceNotFound = errors.New("driver instance not found") + // ErrAutomationNotFound maps to Connect NotFound for automation APIs. + ErrAutomationNotFound = errors.New("automation not found") + // ErrScriptNotFound maps to Connect NotFound for script APIs. + ErrScriptNotFound = errors.New("script not found") + // ErrAutomationDisabled maps to Connect FailedPrecondition. + ErrAutomationDisabled = errors.New("automation disabled") + // ErrRunNotFound maps to Connect NotFound for run-scoped APIs. + ErrRunNotFound = errors.New("run not found") + // ErrRunAlreadyFinished maps to Connect FailedPrecondition for cancellation. + ErrRunAlreadyFinished = errors.New("run already finished") + // ErrCapabilityUnknown maps to Connect InvalidArgument for unsupported calls. + ErrCapabilityUnknown = errors.New("capability unknown") + // ErrDriverUnavailable maps to Connect Unavailable while a driver is down. + ErrDriverUnavailable = errors.New("driver unavailable") + // ErrSubscriptionOverflow maps to Connect ResourceExhausted for slow streams. ErrSubscriptionOverflow = errors.New("subscription overflow") - ErrValidationFailed = errors.New("validation failed") - ErrNotImplemented = errors.New("not implemented") - ErrPathEscape = errors.New("path escapes config dir") + // ErrValidationFailed maps to Connect InvalidArgument for user input. + ErrValidationFailed = errors.New("validation failed") + // ErrNotImplemented maps to Connect Unimplemented for reserved surfaces. + ErrNotImplemented = errors.New("not implemented") + // ErrPathEscape maps to Connect InvalidArgument for config path traversal. + ErrPathEscape = errors.New("path escapes config dir") ) +// ToConnect converts domain errors to Connect errors with structured details. func ToConnect(ctx context.Context, err error, reason string) error { code := classify(err) msg := err.Error() diff --git a/internal/api/listener/doc.go b/internal/api/listener/doc.go new file mode 100644 index 00000000..11a0c28d --- /dev/null +++ b/internal/api/listener/doc.go @@ -0,0 +1,6 @@ +// Package listener builds the daemon's Connect-RPC HTTP listeners. +// +// It owns TCP and Unix-domain socket setup, h2c routing, request interceptors, +// peer-credential extraction, health endpoints, and graceful shutdown. Service +// implementations stay outside this package; listener only adapts them to HTTP. +package listener diff --git a/internal/api/listener/listener.go b/internal/api/listener/listener.go index ffc58959..74d18253 100644 --- a/internal/api/listener/listener.go +++ b/internal/api/listener/listener.go @@ -14,6 +14,7 @@ import ( "github.com/fdatoo/switchyard/internal/api" ) +// Config selects the listener sockets and optional TCP TLS credentials. type Config struct { UDSPath string UDSMode os.FileMode @@ -24,6 +25,7 @@ type Config struct { TLSKeyFile string } +// Deps contains handlers mounted by the listener. type Deps struct { HealthProbe func() error ConnectRoutes []Route @@ -33,11 +35,13 @@ type Deps struct { WebHandler http.Handler // SPA handler — mounted as catch-all } +// Route is one Connect-RPC path and handler pair. type Route struct { Path string Handler http.Handler } +// Listener owns the daemon's TCP and Unix-domain HTTP servers. type Listener struct { cfg Config deps Deps @@ -49,6 +53,7 @@ type Listener struct { startedCh chan struct{} } +// Build validates dependencies and returns an unstarted listener. func Build(cfg Config, deps Deps) (*Listener, error) { if deps.HealthProbe == nil { return nil, errors.New("listener: HealthProbe required") @@ -56,6 +61,7 @@ func Build(cfg Config, deps Deps) (*Listener, error) { return &Listener{cfg: cfg, deps: deps, startedCh: make(chan struct{})}, nil } +// Start binds TCP and Unix-domain sockets, then serves until shutdown. func (l *Listener) Start(ctx context.Context) error { l.mu.Lock() defer l.mu.Unlock() @@ -136,6 +142,7 @@ func (l *Listener) serve(ls net.Listener) { } } +// TCPAddr returns the bound TCP address after Start succeeds. func (l *Listener) TCPAddr() net.Addr { l.mu.Lock() defer l.mu.Unlock() @@ -145,6 +152,8 @@ func (l *Listener) TCPAddr() net.Addr { return l.tcpLis.Addr() } +// Shutdown gracefully drains active requests and removes the Unix socket. +// //nolint:contextcheck // ctx may be nil (caller passes nil to trigger a default timeout); Background() is intentional func (l *Listener) Shutdown(ctx context.Context) error { l.mu.Lock() @@ -164,6 +173,7 @@ func (l *Listener) Shutdown(ctx context.Context) error { return err } +// Close immediately closes listeners and removes the Unix socket. func (l *Listener) Close() error { l.mu.Lock() srv := l.srv diff --git a/internal/api/pagination.go b/internal/api/pagination.go index d82a5261..d1bd2859 100644 --- a/internal/api/pagination.go +++ b/internal/api/pagination.go @@ -7,15 +7,19 @@ import ( ) const ( + // DefaultPageSize is used when a request omits page size. DefaultPageSize = 100 - MaxPageSize = 1000 + // MaxPageSize is the largest page size accepted by API handlers. + MaxPageSize = 1000 ) +// Cursor is the internal decoded form of an opaque pagination cursor. type Cursor struct { Position uint64 Tiebreak string } +// EncodeCursor returns an opaque URL-safe cursor token. func EncodeCursor(c Cursor) (string, error) { if c.Position == 0 && c.Tiebreak == "" { return "", nil @@ -26,6 +30,7 @@ func EncodeCursor(c Cursor) (string, error) { return base64.RawURLEncoding.EncodeToString(buf), nil } +// DecodeCursor parses an opaque URL-safe cursor token. func DecodeCursor(token string) (Cursor, error) { if token == "" { return Cursor{}, nil @@ -43,6 +48,7 @@ func DecodeCursor(token string) (Cursor, error) { }, nil } +// ClampPageSize applies the API default and maximum page-size bounds. func ClampPageSize(n uint32) uint32 { switch { case n == 0: diff --git a/internal/api/service_area.go b/internal/api/service_area.go index 1d00a381..43a6cc0d 100644 --- a/internal/api/service_area.go +++ b/internal/api/service_area.go @@ -9,12 +9,15 @@ import ( "github.com/fdatoo/switchyard/gen/switchyard/v1alpha1/switchyardv1alpha1connect" ) +// AreaService implements read-only area RPCs. type AreaService struct{ be AreaReader } +// NewAreaService returns an area service backed by be. func NewAreaService(be AreaReader) *AreaService { return &AreaService{be: be} } var _ switchyardv1alpha1connect.AreaServiceHandler = (*AreaService)(nil) +// List returns configured areas with API pagination. func (s *AreaService) List(ctx context.Context, req *connect.Request[v1.ListAreasRequest]) (*connect.Response[v1.ListAreasResponse], error) { var tok string var sz uint32 @@ -41,6 +44,7 @@ func (s *AreaService) List(ctx context.Context, req *connect.Request[v1.ListArea return connect.NewResponse(out), nil } +// Get returns one configured area by id. func (s *AreaService) Get(ctx context.Context, req *connect.Request[v1.GetAreaRequest]) (*connect.Response[v1.GetAreaResponse], error) { a, err := s.be.GetArea(ctx, req.Msg.Id) if err != nil { diff --git a/internal/api/service_automation.go b/internal/api/service_automation.go index 6c83e7cd..8e50edce 100644 --- a/internal/api/service_automation.go +++ b/internal/api/service_automation.go @@ -12,12 +12,14 @@ import ( "github.com/fdatoo/switchyard/gen/switchyard/v1alpha1/switchyardv1alpha1connect" ) +// AutomationService implements automation query, control, detail, and trace RPCs. type AutomationService struct { be AutomationControl configs ConfigApplier sys SystemBackend } +// NewAutomationService returns an automation service without detail support. func NewAutomationService(be AutomationControl) *AutomationService { return &AutomationService{be: be} } @@ -30,6 +32,7 @@ func NewAutomationServiceWithDetail(be AutomationControl, configs ConfigApplier, var _ switchyardv1alpha1connect.AutomationServiceHandler = (*AutomationService)(nil) +// List returns automations, optionally filtered by area. func (s *AutomationService) List(ctx context.Context, req *connect.Request[v1.ListAutomationsRequest]) (*connect.Response[v1.ListAutomationsResponse], error) { cur, err := DecodeCursor(pageToken(req.Msg.Page)) if err != nil { @@ -62,6 +65,7 @@ func stringInSlice(s []string, want string) bool { return false } +// Get returns one automation summary by id. func (s *AutomationService) Get(ctx context.Context, req *connect.Request[v1.GetAutomationRequest]) (*connect.Response[v1.GetAutomationResponse], error) { a, err := s.be.Get(ctx, req.Msg.Id) if err != nil { @@ -70,6 +74,7 @@ func (s *AutomationService) Get(ctx context.Context, req *connect.Request[v1.Get return connect.NewResponse(&v1.GetAutomationResponse{Automation: automationToProto(a)}), nil } +// GetDetail returns an automation summary plus source-adjacent editor data. func (s *AutomationService) GetDetail(ctx context.Context, req *connect.Request[v1.GetAutomationDetailRequest]) (*connect.Response[v1.GetAutomationDetailResponse], error) { if s.configs == nil || s.sys == nil { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("GetDetail not configured")) @@ -111,6 +116,7 @@ func (s *AutomationService) GetDetail(ctx context.Context, req *connect.Request[ }), nil } +// Enable marks an automation enabled. func (s *AutomationService) Enable(ctx context.Context, req *connect.Request[v1.EnableAutomationRequest]) (*connect.Response[v1.EnableAutomationResponse], error) { a, err := s.be.SetEnabled(ctx, req.Msg.Id, true, principalID(ctx)) if err != nil { @@ -119,6 +125,7 @@ func (s *AutomationService) Enable(ctx context.Context, req *connect.Request[v1. return connect.NewResponse(&v1.EnableAutomationResponse{Automation: automationToProto(a)}), nil } +// Disable marks an automation disabled. func (s *AutomationService) Disable(ctx context.Context, req *connect.Request[v1.DisableAutomationRequest]) (*connect.Response[v1.DisableAutomationResponse], error) { a, err := s.be.SetEnabled(ctx, req.Msg.Id, false, principalID(ctx)) if err != nil { @@ -127,6 +134,7 @@ func (s *AutomationService) Disable(ctx context.Context, req *connect.Request[v1 return connect.NewResponse(&v1.DisableAutomationResponse{Automation: automationToProto(a)}), nil } +// Trigger starts one automation run immediately. func (s *AutomationService) Trigger(ctx context.Context, req *connect.Request[v1.TriggerAutomationRequest]) (*connect.Response[v1.TriggerAutomationResponse], error) { runID, err := s.be.Trigger(ctx, req.Msg.Id, principalID(ctx)) if err != nil { @@ -135,6 +143,7 @@ func (s *AutomationService) Trigger(ctx context.Context, req *connect.Request[v1 return connect.NewResponse(&v1.TriggerAutomationResponse{RunId: runID}), nil } +// Trace streams automation trace events and idle heartbeats. func (s *AutomationService) Trace(ctx context.Context, req *connect.Request[v1.TraceAutomationRequest], stream *connect.ServerStream[v1.TraceAutomationResponse]) error { cfg := currentStreamConfig() src, cancel, err := s.be.Trace(ctx, req.Msg.Id, req.Msg.RunId, req.Msg.FromCursor) diff --git a/internal/api/service_config.go b/internal/api/service_config.go index 1a810e72..f69375a3 100644 --- a/internal/api/service_config.go +++ b/internal/api/service_config.go @@ -10,12 +10,15 @@ import ( "github.com/fdatoo/switchyard/internal/compute" ) +// ConfigService implements validation, apply, reload, and snapshot RPCs. type ConfigService struct{ be ConfigApplier } +// NewConfigService returns a config service backed by be. func NewConfigService(be ConfigApplier) *ConfigService { return &ConfigService{be: be} } var _ switchyardv1alpha1connect.ConfigServiceHandler = (*ConfigService)(nil) +// Validate checks a Pkl bundle without changing daemon state. func (s *ConfigService) Validate(ctx context.Context, req *connect.Request[v1.ValidateConfigRequest]) (*connect.Response[v1.ValidateConfigResponse], error) { valid, errs, diff, hash, err := s.be.Validate(ctx, req.Msg.PklBundle) if err != nil { @@ -29,6 +32,7 @@ func (s *ConfigService) Validate(ctx context.Context, req *connect.Request[v1.Va }), nil } +// Apply validates and applies a Pkl bundle, or dry-runs when requested. func (s *ConfigService) Apply(ctx context.Context, req *connect.Request[v1.ApplyConfigRequest]) (*connect.Response[v1.ApplyConfigResponse], error) { result, err := s.be.Apply(ctx, req.Msg.PklBundle, req.Msg.Message, req.Msg.ExpectedBundleHash, req.Msg.DryRun, req.Msg.Strict, principalID(ctx)) if err != nil { @@ -43,6 +47,7 @@ func (s *ConfigService) Apply(ctx context.Context, req *connect.Request[v1.Apply }), nil } +// Reload re-reads config from disk and applies any delta. func (s *ConfigService) Reload(ctx context.Context, _ *connect.Request[v1.ReloadConfigRequest]) (*connect.Response[v1.ReloadConfigResponse], error) { diff, correlationID, err := s.be.Reload(ctx, principalID(ctx)) if err != nil { @@ -55,6 +60,7 @@ func (s *ConfigService) Reload(ctx context.Context, _ *connect.Request[v1.Reload }), nil } +// Subscribe streams config-applied notifications and idle heartbeats. func (s *ConfigService) Subscribe(ctx context.Context, _ *connect.Request[v1.SubscribeConfigRequest], stream *connect.ServerStream[v1.SubscribeConfigEvent]) error { src, cancel := s.be.SubscribeConfig() defer cancel() @@ -92,6 +98,7 @@ func (s *ConfigService) Subscribe(ctx context.Context, _ *connect.Request[v1.Sub } } +// GetArtifact returns the current compiled config snapshot. func (s *ConfigService) GetArtifact(ctx context.Context, _ *connect.Request[v1.GetConfigArtifactRequest]) (*connect.Response[v1.GetConfigArtifactResponse], error) { snap, err := s.be.CurrentArtifact(ctx) if err != nil { @@ -100,6 +107,7 @@ func (s *ConfigService) GetArtifact(ctx context.Context, _ *connect.Request[v1.G return connect.NewResponse(&v1.GetConfigArtifactResponse{Snapshot: snap}), nil } +// EvalCompute evaluates a computed dashboard expression. func (s *ConfigService) EvalCompute(ctx context.Context, req *connect.Request[v1.EvalComputeRequest]) (*connect.Response[v1.EvalComputeResponse], error) { svc := compute.NewService() result := svc.Eval(ctx, compute.Request{ diff --git a/internal/api/service_device.go b/internal/api/service_device.go index f4cd8445..9ddade14 100644 --- a/internal/api/service_device.go +++ b/internal/api/service_device.go @@ -10,17 +10,20 @@ import ( "github.com/fdatoo/switchyard/internal/auth" ) +// DeviceService implements device read and metadata mutation RPCs. type DeviceService struct { r DeviceReader w DeviceWriter } +// NewDeviceService returns a device service backed by read and write dependencies. func NewDeviceService(r DeviceReader, w DeviceWriter) *DeviceService { return &DeviceService{r: r, w: w} } var _ switchyardv1alpha1connect.DeviceServiceHandler = (*DeviceService)(nil) +// List returns devices, optionally filtered by area. func (s *DeviceService) List(ctx context.Context, req *connect.Request[v1.ListDevicesRequest]) (*connect.Response[v1.ListDevicesResponse], error) { cur, err := DecodeCursor(pageToken(req.Msg.Page)) if err != nil { @@ -40,6 +43,7 @@ func (s *DeviceService) List(ctx context.Context, req *connect.Request[v1.ListDe return connect.NewResponse(out), nil } +// Get returns one device by id. func (s *DeviceService) Get(ctx context.Context, req *connect.Request[v1.GetDeviceRequest]) (*connect.Response[v1.GetDeviceResponse], error) { d, err := s.r.GetDevice(ctx, req.Msg.Id) if err != nil { @@ -48,6 +52,7 @@ func (s *DeviceService) Get(ctx context.Context, req *connect.Request[v1.GetDevi return connect.NewResponse(&v1.GetDeviceResponse{Device: deviceToProto(d)}), nil } +// Rename changes a device's friendly name and records the acting principal. func (s *DeviceService) Rename(ctx context.Context, req *connect.Request[v1.RenameDeviceRequest]) (*connect.Response[v1.RenameDeviceResponse], error) { if req.Msg.NewFriendlyName == "" { return nil, ToConnect(ctx, ErrValidationFailed, "empty_friendly_name") @@ -59,6 +64,7 @@ func (s *DeviceService) Rename(ctx context.Context, req *connect.Request[v1.Rena return connect.NewResponse(&v1.RenameDeviceResponse{Device: deviceToProto(d)}), nil } +// Reassign moves a device to a new area and records the acting principal. func (s *DeviceService) Reassign(ctx context.Context, req *connect.Request[v1.ReassignDeviceRequest]) (*connect.Response[v1.ReassignDeviceResponse], error) { d, err := s.w.ReassignDevice(ctx, req.Msg.Id, req.Msg.NewAreaId, principalID(ctx)) if err != nil { diff --git a/internal/api/service_driver.go b/internal/api/service_driver.go index a46d9822..65af89dc 100644 --- a/internal/api/service_driver.go +++ b/internal/api/service_driver.go @@ -11,12 +11,15 @@ import ( "github.com/fdatoo/switchyard/gen/switchyard/v1alpha1/switchyardv1alpha1connect" ) +// DriverService implements driver catalog and instance-control RPCs. type DriverService struct{ be DriverControl } +// NewDriverService returns a driver service backed by be. func NewDriverService(be DriverControl) *DriverService { return &DriverService{be: be} } var _ switchyardv1alpha1connect.DriverServiceHandler = (*DriverService)(nil) +// ListDrivers returns available driver implementations. func (s *DriverService) ListDrivers(ctx context.Context, req *connect.Request[v1.ListDriversRequest]) (*connect.Response[v1.ListDriversResponse], error) { cur, err := DecodeCursor(pageToken(req.Msg.Page)) if err != nil { @@ -41,6 +44,7 @@ func (s *DriverService) ListDrivers(ctx context.Context, req *connect.Request[v1 return connect.NewResponse(out), nil } +// ListInstances returns configured driver processes and runtime state. func (s *DriverService) ListInstances(ctx context.Context, req *connect.Request[v1.ListInstancesRequest]) (*connect.Response[v1.ListInstancesResponse], error) { cur, err := DecodeCursor(pageToken(req.Msg.Page)) if err != nil { @@ -70,6 +74,7 @@ func (s *DriverService) ListInstances(ctx context.Context, req *connect.Request[ return connect.NewResponse(out), nil } +// InstanceHealth probes one driver instance. func (s *DriverService) InstanceHealth(ctx context.Context, req *connect.Request[v1.InstanceHealthRequest]) (*connect.Response[v1.InstanceHealthResponse], error) { ok, detail, err := s.be.InstanceHealth(ctx, req.Msg.InstanceId) if err != nil { @@ -78,6 +83,7 @@ func (s *DriverService) InstanceHealth(ctx context.Context, req *connect.Request return connect.NewResponse(&v1.InstanceHealthResponse{Ok: ok, Detail: detail}), nil } +// RestartInstance requests a supervised restart for one driver instance. func (s *DriverService) RestartInstance(ctx context.Context, req *connect.Request[v1.RestartInstanceRequest]) (*connect.Response[v1.RestartInstanceResponse], error) { if err := s.be.RestartInstance(ctx, req.Msg.InstanceId, req.Msg.Reason, principalID(ctx)); err != nil { return nil, ToConnect(ctx, err, "restart_failed") diff --git a/internal/api/service_entity.go b/internal/api/service_entity.go index f0983f34..2726f605 100644 --- a/internal/api/service_entity.go +++ b/internal/api/service_entity.go @@ -13,6 +13,7 @@ import ( "github.com/fdatoo/switchyard/internal/policy" ) +// EntityService implements entity query, command, and subscription RPCs. type EntityService struct { r EntityReader caller CapabilityCaller @@ -24,6 +25,7 @@ type EntityService struct { policyRuntime *policy.Runtime // nil until wired; filter passes through if nil } +// NewEntityService returns an entity service backed by registry reads and capability dispatch. func NewEntityService(r EntityReader, caller CapabilityCaller) *EntityService { return &EntityService{r: r, caller: caller} } @@ -36,6 +38,7 @@ func (s *EntityService) SetPolicyRuntime(rt *policy.Runtime) { s.policyRuntime = var _ switchyardv1alpha1connect.EntityServiceHandler = (*EntityService)(nil) +// List returns entities matching the request selector. func (s *EntityService) List(ctx context.Context, req *connect.Request[v1.ListEntitiesRequest]) (*connect.Response[v1.ListEntitiesResponse], error) { cur, err := DecodeCursor(pageToken(req.Msg.Page)) if err != nil { @@ -56,6 +59,7 @@ func (s *EntityService) List(ctx context.Context, req *connect.Request[v1.ListEn return connect.NewResponse(out), nil } +// Get returns one entity by id. func (s *EntityService) Get(ctx context.Context, req *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.GetEntityResponse], error) { e, err := s.r.GetEntity(ctx, req.Msg.Id) if err != nil { @@ -64,6 +68,7 @@ func (s *EntityService) Get(ctx context.Context, req *connect.Request[v1.GetEnti return connect.NewResponse(&v1.GetEntityResponse{Entity: entityToProto(e)}), nil } +// CallCapability dispatches a command to the entity's owning driver. func (s *EntityService) CallCapability(ctx context.Context, req *connect.Request[v1.CallCapabilityRequest]) (*connect.Response[v1.CallCapabilityResponse], error) { if req.Msg.EntityId == "" || req.Msg.Capability == "" { return nil, ToConnect(ctx, ErrValidationFailed, "missing_required_field") @@ -83,6 +88,7 @@ func (s *EntityService) CallCapability(ctx context.Context, req *connect.Request }), nil } +// Subscribe streams policy-filtered entity changes and idle heartbeats. func (s *EntityService) Subscribe(ctx context.Context, req *connect.Request[v1.SubscribeEntitiesRequest], stream *connect.ServerStream[v1.SubscribeEntitiesResponse]) error { if s.streamSource == nil { return ToConnect(ctx, ErrNotImplemented, "subscribe_unimplemented") diff --git a/internal/api/service_event.go b/internal/api/service_event.go index 6f12ef9c..b41c9a81 100644 --- a/internal/api/service_event.go +++ b/internal/api/service_event.go @@ -10,12 +10,15 @@ import ( "github.com/fdatoo/switchyard/gen/switchyard/v1alpha1/switchyardv1alpha1connect" ) +// EventService implements historical event queries and live tails. type EventService struct{ be EventSource } +// NewEventService returns an event service backed by be. func NewEventService(be EventSource) *EventService { return &EventService{be: be} } var _ switchyardv1alpha1connect.EventServiceHandler = (*EventService)(nil) +// Query returns historical events matching the request filter. func (s *EventService) Query(ctx context.Context, req *connect.Request[v1.QueryEventsRequest]) (*connect.Response[v1.QueryEventsResponse], error) { cur, err := DecodeCursor(pageToken(req.Msg.Page)) if err != nil { @@ -36,6 +39,7 @@ func (s *EventService) Query(ctx context.Context, req *connect.Request[v1.QueryE return connect.NewResponse(out), nil } +// Tail streams live events and idle heartbeats. func (s *EventService) Tail(ctx context.Context, req *connect.Request[v1.TailEventsRequest], stream *connect.ServerStream[v1.TailEventsResponse]) error { cfg := currentStreamConfig() filter := eventFilterFromProto(req.Msg.Filter) diff --git a/internal/api/service_scene.go b/internal/api/service_scene.go index 424519f5..af73b3e4 100644 --- a/internal/api/service_scene.go +++ b/internal/api/service_scene.go @@ -32,12 +32,14 @@ type RealSceneService struct { logger *slog.Logger } +// NewRealSceneService returns a scene service backed by live config snapshots and invocation. func NewRealSceneService(snap SceneSnapshotReader, invoke SceneInvoker, logger *slog.Logger) *RealSceneService { return &RealSceneService{snap: snap, invoke: invoke, logger: logger} } var _ switchyardv1alpha1connect.SceneServiceHandler = (*RealSceneService)(nil) +// List returns configured scenes from the current config snapshot. func (s *RealSceneService) List(_ context.Context, _ *connect.Request[v1.ListScenesRequest]) (*connect.Response[v1.ListScenesResponse], error) { snap := s.snap.Current() out := make([]*v1.Scene, 0, len(snap.GetScenes())) @@ -51,6 +53,7 @@ func (s *RealSceneService) List(_ context.Context, _ *connect.Request[v1.ListSce return connect.NewResponse(&v1.ListScenesResponse{Scenes: out}), nil } +// Apply invokes a scene and returns the generated correlation id. func (s *RealSceneService) Apply(ctx context.Context, req *connect.Request[v1.ApplySceneRequest]) (*connect.Response[v1.ApplySceneResponse], error) { corrID := uuid.NewString() err := s.invoke.Invoke(ctx, req.Msg.GetId(), corrID, "rpc:"+principalID(ctx)) @@ -63,6 +66,7 @@ func (s *RealSceneService) Apply(ctx context.Context, req *connect.Request[v1.Ap return connect.NewResponse(&v1.ApplySceneResponse{CorrelationId: corrID}), nil } +// Preview returns human-readable action lines without running the scene. func (s *RealSceneService) Preview(_ context.Context, req *connect.Request[v1.PreviewSceneRequest]) (*connect.Response[v1.PreviewSceneResponse], error) { snap := s.snap.Current() var scene *configv1.SceneConfig diff --git a/internal/api/service_script.go b/internal/api/service_script.go index 20070e7f..c211cf19 100644 --- a/internal/api/service_script.go +++ b/internal/api/service_script.go @@ -17,18 +17,21 @@ import ( "github.com/fdatoo/switchyard/internal/auth" ) +// ScriptService implements Starlark script listing, execution, eval, and tests. type ScriptService struct { be ScriptRunner events EventAppender mcpCaps MCPCapsProvider } +// NewScriptService returns a script service backed by runner, event audit, and MCP caps. func NewScriptService(be ScriptRunner, events EventAppender, caps MCPCapsProvider) *ScriptService { return &ScriptService{be: be, events: events, mcpCaps: caps} } var _ switchyardv1alpha1connect.ScriptServiceHandler = (*ScriptService)(nil) +// List returns invocable scripts with API pagination. func (s *ScriptService) List(ctx context.Context, req *connect.Request[v1.ListScriptsRequest]) (*connect.Response[v1.ListScriptsResponse], error) { cur, err := DecodeCursor(pageToken(req.Msg.Page)) if err != nil { @@ -48,6 +51,7 @@ func (s *ScriptService) List(ctx context.Context, req *connect.Request[v1.ListSc return connect.NewResponse(out), nil } +// Run invokes a named script with structured arguments. func (s *ScriptService) Run(ctx context.Context, req *connect.Request[v1.RunScriptRequest]) (*connect.Response[v1.RunScriptResponse], error) { var args map[string]any if req.Msg.Args != nil { @@ -63,6 +67,7 @@ func (s *ScriptService) Run(ctx context.Context, req *connect.Request[v1.RunScri }), nil } +// Cancel requests cancellation for a running script. func (s *ScriptService) Cancel(ctx context.Context, req *connect.Request[v1.CancelScriptRequest]) (*connect.Response[v1.CancelScriptResponse], error) { if err := s.be.Cancel(ctx, req.Msg.RunId); err != nil { return nil, ToConnect(ctx, err, "cancel_failed") @@ -70,6 +75,7 @@ func (s *ScriptService) Cancel(ctx context.Context, req *connect.Request[v1.Canc return connect.NewResponse(&v1.CancelScriptResponse{}), nil } +// Eval executes ad hoc Starlark and audits MCP-originated evaluations. func (s *ScriptService) Eval(ctx context.Context, req *connect.Request[v1.EvalScriptRequest]) (*connect.Response[v1.EvalScriptResponse], error) { source, _ := SourceFromContext(ctx) fromMCP := source == "mcp" @@ -138,6 +144,7 @@ func errString(err error) string { return err.Error() } +// RunTests streams Starlark test results and idle heartbeats. func (s *ScriptService) RunTests(ctx context.Context, req *connect.Request[v1.RunTestsRequest], stream *connect.ServerStream[v1.RunTestsResponse]) error { if req.Msg.Path == "" { return ToConnect(ctx, ErrValidationFailed, "missing_path") diff --git a/internal/api/service_system.go b/internal/api/service_system.go index 29e22223..03042101 100644 --- a/internal/api/service_system.go +++ b/internal/api/service_system.go @@ -11,16 +11,19 @@ import ( "github.com/fdatoo/switchyard/internal/auth" ) +// SystemService implements daemon metadata, health, diagnostics, and admin RPCs. type SystemService struct { be SystemBackend } +// NewSystemService returns a system service backed by be. func NewSystemService(be SystemBackend) *SystemService { return &SystemService{be: be} } var _ switchyardv1alpha1connect.SystemServiceHandler = (*SystemService)(nil) +// Version returns build and schema metadata. func (s *SystemService) Version(_ context.Context, _ *connect.Request[systemv1.VersionRequest]) (*connect.Response[systemv1.VersionResponse], error) { v := s.be.Version() return connect.NewResponse(&systemv1.VersionResponse{ @@ -31,6 +34,7 @@ func (s *SystemService) Version(_ context.Context, _ *connect.Request[systemv1.V }), nil } +// Health returns aggregate daemon health and subsystem details. func (s *SystemService) Health(ctx context.Context, _ *connect.Request[systemv1.HealthRequest]) (*connect.Response[systemv1.HealthResponse], error) { ok, summary, subs := s.be.Health(ctx) out := &systemv1.HealthResponse{Ok: ok, Summary: summary} @@ -42,6 +46,7 @@ func (s *SystemService) Health(ctx context.Context, _ *connect.Request[systemv1. return connect.NewResponse(out), nil } +// Metrics returns a Prometheus text exposition snapshot. func (s *SystemService) Metrics(ctx context.Context, _ *connect.Request[systemv1.MetricsRequest]) (*connect.Response[systemv1.MetricsResponse], error) { text, err := s.be.MetricsText() if err != nil { @@ -50,6 +55,7 @@ func (s *SystemService) Metrics(ctx context.Context, _ *connect.Request[systemv1 return connect.NewResponse(&systemv1.MetricsResponse{PrometheusText: text}), nil } +// Diagnostics returns an operator support bundle. func (s *SystemService) Diagnostics(ctx context.Context, _ *connect.Request[systemv1.DiagnosticsRequest]) (*connect.Response[systemv1.DiagnosticsResponse], error) { bundle, hash, t, err := s.be.Diagnostics(ctx) if err != nil { @@ -62,6 +68,7 @@ func (s *SystemService) Diagnostics(ctx context.Context, _ *connect.Request[syst }), nil } +// CreateSnapshot asks the eventstore to write a named projection snapshot. func (s *SystemService) CreateSnapshot(ctx context.Context, req *connect.Request[systemv1.CreateSnapshotRequest]) (*connect.Response[systemv1.CreateSnapshotResponse], error) { cursor, t, err := s.be.CreateSnapshot(ctx, req.Msg.Owner, req.Msg.Reason) if err != nil { @@ -73,6 +80,7 @@ func (s *SystemService) CreateSnapshot(ctx context.Context, req *connect.Request }), nil } +// GetConfigDir returns the daemon's active config directory. func (s *SystemService) GetConfigDir(ctx context.Context, _ *connect.Request[systemv1.GetConfigDirRequest]) (*connect.Response[systemv1.GetConfigDirResponse], error) { dir, err := s.be.ConfigDir(ctx) if err != nil { @@ -81,6 +89,7 @@ func (s *SystemService) GetConfigDir(ctx context.Context, _ *connect.Request[sys return connect.NewResponse(&systemv1.GetConfigDirResponse{ConfigDir: dir}), nil } +// GetMCPConfig returns MCP runtime caps from daemon configuration. func (s *SystemService) GetMCPConfig(ctx context.Context, _ *connect.Request[systemv1.GetMCPConfigRequest]) (*connect.Response[systemv1.GetMCPConfigResponse], error) { cfg, err := s.be.MCPConfig(ctx) if err != nil { @@ -96,6 +105,7 @@ func (s *SystemService) GetMCPConfig(ctx context.Context, _ *connect.Request[sys }), nil } +// RecordConfigFileEdit appends an audit event for a config-file edit session. func (s *SystemService) RecordConfigFileEdit(ctx context.Context, req *connect.Request[systemv1.RecordConfigFileEditRequest]) (*connect.Response[systemv1.RecordConfigFileEditResponse], error) { p, ok := auth.PrincipalFromContext(ctx) if !ok { diff --git a/internal/api/service_zone.go b/internal/api/service_zone.go index b8c9907a..e73b98c8 100644 --- a/internal/api/service_zone.go +++ b/internal/api/service_zone.go @@ -9,12 +9,15 @@ import ( "github.com/fdatoo/switchyard/gen/switchyard/v1alpha1/switchyardv1alpha1connect" ) +// ZoneService implements read-only zone RPCs. type ZoneService struct{ be ZoneReader } +// NewZoneService returns a zone service backed by be. func NewZoneService(be ZoneReader) *ZoneService { return &ZoneService{be: be} } var _ switchyardv1alpha1connect.ZoneServiceHandler = (*ZoneService)(nil) +// List returns configured zones with API pagination. func (s *ZoneService) List(ctx context.Context, req *connect.Request[v1.ListZonesRequest]) (*connect.Response[v1.ListZonesResponse], error) { var tok string var sz uint32 @@ -41,6 +44,7 @@ func (s *ZoneService) List(ctx context.Context, req *connect.Request[v1.ListZone return connect.NewResponse(out), nil } +// Get returns one configured zone by id. func (s *ZoneService) Get(ctx context.Context, req *connect.Request[v1.GetZoneRequest]) (*connect.Response[v1.GetZoneResponse], error) { z, err := s.be.GetZone(ctx, req.Msg.Id) if err != nil { diff --git a/internal/api/streaming.go b/internal/api/streaming.go index 584263af..fa3f12e5 100644 --- a/internal/api/streaming.go +++ b/internal/api/streaming.go @@ -6,6 +6,7 @@ import ( "time" ) +// HeartbeatTicker emits stream heartbeats only after an idle interval. type HeartbeatTicker struct { interval time.Duration c chan time.Time @@ -14,6 +15,7 @@ type HeartbeatTicker struct { once sync.Once } +// NewHeartbeatTicker starts a heartbeat ticker tied to ctx. func NewHeartbeatTicker(ctx context.Context, interval time.Duration) *HeartbeatTicker { t := &HeartbeatTicker{ interval: interval, @@ -53,8 +55,10 @@ func (t *HeartbeatTicker) run(ctx context.Context) { } } +// C returns the channel that receives idle heartbeat ticks. func (t *HeartbeatTicker) C() <-chan time.Time { return t.c } +// NotePayloadSent resets the idle timer after a real stream payload is sent. func (t *HeartbeatTicker) NotePayloadSent() { select { case t.resetCh <- struct{}{}: @@ -62,6 +66,7 @@ func (t *HeartbeatTicker) NotePayloadSent() { } } +// Stop terminates the ticker goroutine. func (t *HeartbeatTicker) Stop() { t.once.Do(func() { close(t.done) }) } diff --git a/internal/api/subscription_filter.go b/internal/api/subscription_filter.go index 4f8e0d4d..de411422 100644 --- a/internal/api/subscription_filter.go +++ b/internal/api/subscription_filter.go @@ -12,11 +12,13 @@ import ( "github.com/fdatoo/switchyard/internal/policy" ) +// EntityFilter applies policy behavior for entity subscriptions. type EntityFilter struct { rt *policy.Runtime mode commonpb.PolicyMode } +// NewEntityFilter returns a subscription filter with FILTER as the default mode. func NewEntityFilter(rt *policy.Runtime, mode commonpb.PolicyMode) *EntityFilter { if mode == commonpb.PolicyMode_POLICY_MODE_UNSPECIFIED { mode = commonpb.PolicyMode_POLICY_MODE_FILTER @@ -43,6 +45,7 @@ func (f *EntityFilter) Preflight(ctx context.Context, p auth.Principal, candidat return allowed, nil } +// AllowsEntity reports whether one subscription update may be sent to p. func (f *EntityFilter) AllowsEntity(ctx context.Context, p auth.Principal, t policy.Target) bool { if f.rt == nil { return true diff --git a/internal/api/time.go b/internal/api/time.go index 0399a2fe..c5c86254 100644 --- a/internal/api/time.go +++ b/internal/api/time.go @@ -6,6 +6,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +// ProtoTime converts zero Go times to nil protobuf timestamps. func ProtoTime(t time.Time) *timestamppb.Timestamp { if t.IsZero() { return nil @@ -13,6 +14,7 @@ func ProtoTime(t time.Time) *timestamppb.Timestamp { return timestamppb.New(t) } +// GoTime converts nil protobuf timestamps to the zero Go time. func GoTime(ts *timestamppb.Timestamp) time.Time { if ts == nil { return time.Time{} diff --git a/internal/auth/audit/recorder.go b/internal/auth/audit/recorder.go index 78aa3ce2..2045c8c5 100644 --- a/internal/auth/audit/recorder.go +++ b/internal/auth/audit/recorder.go @@ -10,12 +10,15 @@ import ( "github.com/fdatoo/switchyard/internal/eventstore" ) +// Recorder appends authentication and policy audit events. type Recorder struct { es eventstore.Appender } +// New returns an audit recorder that writes to es. func New(es eventstore.Appender) *Recorder { return &Recorder{es: es} } +// Identity captures request identity metadata shared by all auth audit events. type Identity struct { PrincipalID string SourceIP string @@ -104,92 +107,154 @@ func (r *Recorder) emit(ctx context.Context, id Identity, kind interface{}) erro return r.es.AppendAuth(ctx, e) } +// LoginSucceeded records a successful login. func (r *Recorder) LoginSucceeded(ctx context.Context, id Identity, k LoginSucceeded) error { return r.emit(ctx, id, k) } + +// LoginFailed records a rejected login attempt. func (r *Recorder) LoginFailed(ctx context.Context, id Identity, k LoginFailed) error { return r.emit(ctx, id, k) } + +// Logout records a user logout. func (r *Recorder) Logout(ctx context.Context, id Identity, k Logout) error { return r.emit(ctx, id, k) } + +// SessionRefreshed records a successful refresh-token rotation. func (r *Recorder) SessionRefreshed(ctx context.Context, id Identity, k SessionRefreshed) error { return r.emit(ctx, id, k) } + +// SessionReplayDetected records refresh-token replay detection. func (r *Recorder) SessionReplayDetected(ctx context.Context, id Identity, k SessionReplayDetected) error { return r.emit(ctx, id, k) } + +// PasswordChanged records password credential creation or replacement. func (r *Recorder) PasswordChanged(ctx context.Context, id Identity, k PasswordChanged) error { return r.emit(ctx, id, k) } + +// PasskeyRegistered records WebAuthn credential registration. func (r *Recorder) PasskeyRegistered(ctx context.Context, id Identity, k PasskeyRegistered) error { return r.emit(ctx, id, k) } + +// PasskeyUnregistered records WebAuthn credential removal. func (r *Recorder) PasskeyUnregistered(ctx context.Context, id Identity, k PasskeyUnregistered) error { return r.emit(ctx, id, k) } + +// EnrollmentTokenMinted records issuance of a one-time enrollment token. func (r *Recorder) EnrollmentTokenMinted(ctx context.Context, id Identity, k EnrollmentTokenMinted) error { return r.emit(ctx, id, k) } + +// EnrollmentTokenRedeemed records use of a one-time enrollment token. func (r *Recorder) EnrollmentTokenRedeemed(ctx context.Context, id Identity, k EnrollmentTokenRedeemed) error { return r.emit(ctx, id, k) } + +// TokenMinted records API token creation. func (r *Recorder) TokenMinted(ctx context.Context, id Identity, k TokenMinted) error { return r.emit(ctx, id, k) } + +// TokenRevoked records API token revocation. func (r *Recorder) TokenRevoked(ctx context.Context, id Identity, k TokenRevoked) error { return r.emit(ctx, id, k) } + +// TokenRejected records a failed API token authentication attempt. func (r *Recorder) TokenRejected(ctx context.Context, id Identity, k TokenRejected) error { return r.emit(ctx, id, k) } + +// PolicyDenied records an authorization denial. func (r *Recorder) PolicyDenied(ctx context.Context, id Identity, k PolicyDenied) error { return r.emit(ctx, id, k) } + +// PolicyCompiled records successful policy compilation. func (r *Recorder) PolicyCompiled(ctx context.Context, id Identity, k PolicyCompiled) error { return r.emit(ctx, id, k) } + +// PolicyBypassed records a request allowed outside normal policy evaluation. func (r *Recorder) PolicyBypassed(ctx context.Context, id Identity, k PolicyBypassed) error { return r.emit(ctx, id, k) } // Domain types per kind, mirroring the proto messages but in plain Go. +// LoginSucceeded is the payload for a successful login audit event. type LoginSucceeded struct { AuthMethod, UserSlug, SessionID, CredentialID string } + +// LoginFailed is the payload for a failed login audit event. type LoginFailed struct { AuthMethod, AttemptedUserSlug, Reason string } + +// Logout is the payload for a logout audit event. type Logout struct{ UserSlug, SessionID string } + +// SessionRefreshed is the payload for a refresh-token rotation audit event. type SessionRefreshed struct{ UserSlug, SessionID, NewSessionID string } + +// SessionReplayDetected is the payload for refresh-token replay detection. type SessionReplayDetected struct { UserSlug, SessionID string RevokedCount uint32 } + +// PasswordChanged is the payload for password credential changes. type PasswordChanged struct{ UserSlug, SetBy string } + +// PasskeyRegistered is the payload for passkey registration. type PasskeyRegistered struct{ UserSlug, CredentialID, Label string } + +// PasskeyUnregistered is the payload for passkey removal. type PasskeyUnregistered struct{ UserSlug, CredentialID, Label string } + +// EnrollmentTokenMinted is the payload for one-time enrollment token issuance. type EnrollmentTokenMinted struct { UserSlug, Intent string ExpiresAt int64 } + +// EnrollmentTokenRedeemed is the payload for one-time enrollment token use. type EnrollmentTokenRedeemed struct{ UserSlug, Intent string } + +// TokenMinted is the payload for API token creation. type TokenMinted struct { UserSlug, TokenID, Label, ScopeSummary, IssuedBy string TTLSeconds uint32 } + +// TokenRevoked is the payload for API token revocation. type TokenRevoked struct{ TokenID, RevokedBy, Reason string } + +// TokenRejected is the payload for a failed API token attempt. type TokenRejected struct{ TokenIDPrefix, Reason string } + +// PolicyDenied is the payload for a policy denial. type PolicyDenied struct { ActionService, ActionMethod, ActionVerb, TargetKind, TargetID, SubReason, RuleName string } + +// PolicyCompiled is the payload for a successful policy compile. type PolicyCompiled struct { Generation uint64 PolicyCount uint32 CompileDurationMs uint32 CompiledBy string } + +// PolicyBypassed is the payload for an explicit policy bypass. type PolicyBypassed struct { ActionService, ActionMethod, ActionVerb, Reason string } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a719d791..ab953231 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -7,11 +7,15 @@ import ( ) var ( - ErrNotApplicable = errors.New("auth: not applicable to this request") + // ErrNotApplicable tells an auth chain to try the next authenticator. + ErrNotApplicable = errors.New("auth: not applicable to this request") + // ErrUnauthenticated means the request presented no valid identity. ErrUnauthenticated = errors.New("auth: unauthenticated") - ErrForbidden = errors.New("auth: forbidden") + // ErrForbidden means the principal is known but not allowed to act. + ErrForbidden = errors.New("auth: forbidden") ) +// Principal is the authenticated actor attached to a request. type Principal struct { ID string DisplayName string @@ -19,6 +23,7 @@ type Principal struct { Metadata map[string]string } +// Request is the transport-neutral authentication input. type Request struct { Scheme string Headers http.Header @@ -28,12 +33,14 @@ type Request struct { HTTP *http.Request } +// Action is the operation an authenticated principal wants to perform. type Action struct { Service string Method string Verb string } +// Target is the optional resource-level authorization subject. type Target struct { Kind string ID string @@ -42,20 +49,24 @@ type Target struct { Attr map[string]string } +// Authenticator resolves a request into a principal or a stable auth error. type Authenticator interface { Authenticate(ctx context.Context, req Request) (Principal, error) } +// Authorizer decides whether a principal may perform an action on a target. type Authorizer interface { Authorize(ctx context.Context, p Principal, a Action, t Target) error } type principalCtxKey struct{} +// WithPrincipal returns a child context carrying the authenticated principal. func WithPrincipal(ctx context.Context, p Principal) context.Context { return context.WithValue(ctx, principalCtxKey{}, p) } +// PrincipalFromContext returns the principal attached by authentication middleware. func PrincipalFromContext(ctx context.Context) (Principal, bool) { p, ok := ctx.Value(principalCtxKey{}).(Principal) return p, ok diff --git a/internal/auth/authn/doc.go b/internal/auth/authn/doc.go new file mode 100644 index 00000000..6baf1166 --- /dev/null +++ b/internal/auth/authn/doc.go @@ -0,0 +1,6 @@ +// Package authn adapts HTTP requests into auth.Authenticator implementations. +// +// It contains the bearer-token and session-cookie authenticators used by the +// Connect listener, plus helpers that preserve Unix-domain peer credentials for +// trusted local daemon traffic. +package authn diff --git a/internal/auth/credentials/doc.go b/internal/auth/credentials/doc.go new file mode 100644 index 00000000..bb056a72 --- /dev/null +++ b/internal/auth/credentials/doc.go @@ -0,0 +1,7 @@ +// Package credentials stores and verifies user-facing authentication material. +// +// It owns password hashes, API tokens, WebAuthn passkeys, and one-time +// enrollment tokens. Callers receive stable sentinel errors for invalid, +// expired, revoked, replayed, or otherwise unusable credentials so API adapters +// can map failures without parsing error strings. +package credentials diff --git a/internal/auth/credentials/enrollment.go b/internal/auth/credentials/enrollment.go index e32a2e9b..157609c1 100644 --- a/internal/auth/credentials/enrollment.go +++ b/internal/auth/credentials/enrollment.go @@ -11,24 +11,36 @@ import ( "time" ) +// IntentRegisterPasskey enrolls a new WebAuthn credential for a user. const IntentRegisterPasskey = "register_passkey" + +// IntentSetPassword enrolls or replaces a user's password credential. const IntentSetPassword = "set_password" +// ErrEnrollmentInvalid means the presented enrollment token cannot be decoded or found. var ErrEnrollmentInvalid = errors.New("credentials: enrollment token invalid") + +// ErrEnrollmentExpired means the enrollment token existed but passed its expiry. var ErrEnrollmentExpired = errors.New("credentials: enrollment token expired") + +// ErrEnrollmentConsumed means the token has already been redeemed. var ErrEnrollmentConsumed = errors.New("credentials: enrollment token already used") +// Enrollment stores and redeems one-time credential enrollment tokens. type Enrollment struct{ db *sql.DB } +// NewEnrollment returns an enrollment-token store backed by db. func NewEnrollment(db *sql.DB) *Enrollment { return &Enrollment{db: db} } +// EnrollmentLookup is the identity and action unlocked by a redeemed token. type EnrollmentLookup struct { UserSlug string Intent string } +// Mint creates a one-time plaintext token and stores only its hash. func (e *Enrollment) Mint(ctx context.Context, userSlug, intent string, ttl time.Duration) (string, error) { if intent != IntentRegisterPasskey && intent != IntentSetPassword { return "", errors.New("credentials: invalid intent") @@ -57,6 +69,7 @@ func (e *Enrollment) Mint(ctx context.Context, userSlug, intent string, ttl time return plaintext, nil } +// Redeem validates a plaintext token, marks it consumed, and returns its lookup data. func (e *Enrollment) Redeem(ctx context.Context, plaintext string) (EnrollmentLookup, error) { secret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(plaintext) if err != nil { @@ -112,6 +125,7 @@ func (e *Enrollment) Redeem(ctx context.Context, plaintext string) (EnrollmentLo return lk, nil } +// Sweep deletes expired or long-consumed enrollment tokens before cutoff. func (e *Enrollment) Sweep(ctx context.Context, cutoff time.Time) error { cutoffUnix := cutoff.Unix() _, err := e.db.ExecContext(ctx, ` diff --git a/internal/auth/credentials/password.go b/internal/auth/credentials/password.go index 5c806eb9..f86f22c5 100644 --- a/internal/auth/credentials/password.go +++ b/internal/auth/credentials/password.go @@ -15,21 +15,25 @@ import ( "golang.org/x/crypto/argon2" ) +// Argon2idParams are the tunable password-hashing parameters. type Argon2idParams struct { Time uint32 MemoryKiB uint32 Parallelism uint8 } +// DefaultArgon2idParams returns the daemon's target password-hashing cost. func DefaultArgon2idParams() Argon2idParams { return Argon2idParams{Time: 3, MemoryKiB: 64 * 1024, Parallelism: 4} } +// Password stores and verifies Argon2id password credentials. type Password struct { db *sql.DB params Argon2idParams } +// NewPassword returns a password store backed by db. func NewPassword(db *sql.DB, p Argon2idParams) *Password { return &Password{db: db, params: p} } @@ -66,6 +70,7 @@ func (p *Password) BootstrapHash(ctx context.Context, userSlug, encoded, setBy s return err } +// Delete removes the user's password credential if one exists. func (p *Password) Delete(ctx context.Context, userSlug string) error { _, err := p.db.ExecContext(ctx, `DELETE FROM auth_passwords WHERE user_slug = ?`, userSlug) return err diff --git a/internal/auth/credentials/tokens.go b/internal/auth/credentials/tokens.go index cbe369ec..3531172a 100644 --- a/internal/auth/credentials/tokens.go +++ b/internal/auth/credentials/tokens.go @@ -15,14 +15,22 @@ import ( "github.com/oklog/ulid/v2" ) +// ErrTokenInvalid means an API token is malformed, unknown, or has the wrong secret. var ErrTokenInvalid = errors.New("credentials: token invalid") + +// ErrTokenRevoked means an API token exists but has been explicitly revoked. var ErrTokenRevoked = errors.New("credentials: token revoked") + +// ErrTokenExpired means an API token exists but is past its expiry. var ErrTokenExpired = errors.New("credentials: token expired") +// Tokens issues, verifies, revokes, and lists API tokens. type Tokens struct{ db *sql.DB } +// NewTokens returns an API-token store backed by db. func NewTokens(db *sql.DB) *Tokens { return &Tokens{db: db} } +// IssueTokenInput is the durable metadata for a newly issued token. type IssueTokenInput struct { UserSlug string Label string @@ -31,6 +39,7 @@ type IssueTokenInput struct { TTL time.Duration // 0 = never expires; negative = born-expired (for testing) } +// Lookup is the verified identity and scope for a presented token. type Lookup struct { TokenID string UserSlug string @@ -39,6 +48,7 @@ type Lookup struct { IssuedBy string } +// Issue creates a new plaintext API token and stores only its hash. func (t *Tokens) Issue(ctx context.Context, in IssueTokenInput) (plaintext, tokenID string, err error) { secret := make([]byte, 24) if _, err = rand.Read(secret); err != nil { @@ -70,6 +80,7 @@ func (t *Tokens) Issue(ctx context.Context, in IssueTokenInput) (plaintext, toke return plaintext, tokenID, nil } +// Verify checks a plaintext API token and returns its stored metadata. func (t *Tokens) Verify(ctx context.Context, plaintext string) (Lookup, error) { parts := strings.SplitN(plaintext, "_", 3) if len(parts) != 3 || parts[0] != "switchyard" { @@ -122,6 +133,7 @@ func (t *Tokens) Verify(ctx context.Context, plaintext string) (Lookup, error) { return lk, nil } +// Revoke marks tokenID revoked if it is still active. func (t *Tokens) Revoke(ctx context.Context, tokenID, byPrincipal string) error { _, err := t.db.ExecContext(ctx, ` UPDATE auth_tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL`, @@ -129,6 +141,7 @@ func (t *Tokens) Revoke(ctx context.Context, tokenID, byPrincipal string) error return err } +// TouchLastUsed records successful token use for audit and admin views. func (t *Tokens) TouchLastUsed(ctx context.Context, tokenID string) error { _, err := t.db.ExecContext(ctx, ` UPDATE auth_tokens SET last_used_at = ? WHERE token_id = ?`, @@ -136,6 +149,7 @@ func (t *Tokens) TouchLastUsed(ctx context.Context, tokenID string) error { return err } +// ListedToken is an API token row prepared for administrative listing. type ListedToken struct { TokenID string UserSlug string @@ -148,6 +162,7 @@ type ListedToken struct { Scope []byte } +// List returns tokens for one user, or all users when userSlug is empty. func (t *Tokens) List(ctx context.Context, userSlug string) ([]ListedToken, error) { var ( rows *sql.Rows diff --git a/internal/auth/credentials/webauthn.go b/internal/auth/credentials/webauthn.go index 1478dec1..e88d6a0a 100644 --- a/internal/auth/credentials/webauthn.go +++ b/internal/auth/credentials/webauthn.go @@ -14,18 +14,24 @@ import ( "github.com/oklog/ulid/v2" ) +// ErrPasskeyUnknown means the credential id is not registered. var ErrPasskeyUnknown = errors.New("credentials: passkey unknown") + +// ErrSignCountRegression means the authenticator reported a cloned credential risk. var ErrSignCountRegression = errors.New("credentials: passkey sign-count regression") +// Passkeys stores and verifies WebAuthn passkey credentials. type Passkeys struct { db *sql.DB w *wa.WebAuthn } +// NewPasskeys returns a WebAuthn credential store backed by db. func NewPasskeys(db *sql.DB, w *wa.WebAuthn) *Passkeys { return &Passkeys{db: db, w: w} } +// Passkey is a registered WebAuthn credential. type Passkey struct { CredentialID []byte UserSlug string diff --git a/internal/auth/doc.go b/internal/auth/doc.go new file mode 100644 index 00000000..e0b6f359 --- /dev/null +++ b/internal/auth/doc.go @@ -0,0 +1,7 @@ +// Package auth defines the daemon's authentication and authorization boundary. +// +// The package contains transport-neutral request, principal, action, and target +// types plus the interfaces implemented by concrete authenticators and +// authorizers. Subpackages handle credential storage, identity projection, +// session cookies, throttling, and request-specific authentication chains. +package auth diff --git a/internal/auth/identity/doc.go b/internal/auth/identity/doc.go new file mode 100644 index 00000000..c91c460d --- /dev/null +++ b/internal/auth/identity/doc.go @@ -0,0 +1,6 @@ +// Package identity projects configured users and roles into the auth database. +// +// Pkl config remains the source of truth. Store applies full snapshots +// atomically, then exposes read APIs used by authenticators, policy evaluation, +// and administrative surfaces. +package identity diff --git a/internal/auth/identity/store.go b/internal/auth/identity/store.go index 423ffa48..aaaf70be 100644 --- a/internal/auth/identity/store.go +++ b/internal/auth/identity/store.go @@ -10,6 +10,7 @@ import ( "github.com/fdatoo/switchyard/internal/storage" ) +// ErrNotFound means the requested configured user is not present. var ErrNotFound = errors.New("identity: user not found") // User represents a user from the Pkl config, with roles expanded into a flat list. diff --git a/internal/auth/local.go b/internal/auth/local.go index 7d64c6d1..4af19334 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -6,8 +6,10 @@ import ( "strconv" ) +// LocalPeerCred authenticates trusted Unix-domain socket clients by peer credentials. type LocalPeerCred struct{} +// Authenticate accepts requests classified as local peer-credential traffic. func (LocalPeerCred) Authenticate(_ context.Context, req Request) (Principal, error) { if req.Scheme != "uds:peercred" || req.PeerCred == nil { return Principal{}, ErrNotApplicable @@ -24,12 +26,15 @@ func (LocalPeerCred) Authenticate(_ context.Context, req Request) (Principal, er }, nil } +// AllowAll authorizes every action and is used when policy enforcement is disabled. type AllowAll struct{} +// Authorize always permits the request. func (AllowAll) Authorize(_ context.Context, _ Principal, _ Action, _ Target) error { return nil } +// Chain tries authenticators in order until one succeeds or returns a terminal error. func Chain(as ...Authenticator) Authenticator { return chain(as) } diff --git a/internal/auth/migrations/migrations.go b/internal/auth/migrations/migrations.go index 08a1f4ab..77604bb5 100644 --- a/internal/auth/migrations/migrations.go +++ b/internal/auth/migrations/migrations.go @@ -3,5 +3,7 @@ package migrations import "embed" +// FS contains auth schema migrations. +// //go:embed *.sql var FS embed.FS diff --git a/internal/auth/reject.go b/internal/auth/reject.go index 5a109286..bf8eba80 100644 --- a/internal/auth/reject.go +++ b/internal/auth/reject.go @@ -2,8 +2,10 @@ package auth import "context" +// RejectAll denies every authentication attempt. type RejectAll struct{} +// Authenticate always returns ErrUnauthenticated. func (RejectAll) Authenticate(_ context.Context, _ Request) (Principal, error) { return Principal{}, ErrUnauthenticated } diff --git a/internal/auth/sessions/cookies.go b/internal/auth/sessions/cookies.go index 566eddad..5e3790ad 100644 --- a/internal/auth/sessions/cookies.go +++ b/internal/auth/sessions/cookies.go @@ -12,9 +12,12 @@ import ( ) var ( + // ErrSessionInvalid means a session cookie is malformed or fails signature checks. ErrSessionInvalid = errors.New("sessions: invalid") + // ErrSessionExpired means a valid session is past its expiration time. ErrSessionExpired = errors.New("sessions: expired") - ErrSessionReplay = errors.New("sessions: refresh replay detected") + // ErrSessionReplay means a refresh token was reused after rotation. + ErrSessionReplay = errors.New("sessions: refresh replay detected") ) // AccessClaim is what the access cookie carries (HMAC-signed). @@ -60,7 +63,10 @@ func decodeAccessCookie(value string, key []byte) (AccessClaim, error) { return AccessClaim{SessionID: parts[0], UserSlug: parts[1], AuthMethod: parts[2], Exp: exp}, nil } -// RefreshCookie carries the refresh secret in plaintext (server-stored hash authoritatively validates). +// RefreshCookie carries the refresh secret in plaintext. +// +// The server-stored hash is authoritative; this type only represents the +// client cookie value before verification. type RefreshCookie struct { SessionID string RefreshSecret string diff --git a/internal/auth/throttle/throttle.go b/internal/auth/throttle/throttle.go index 10be217d..3960d02a 100644 --- a/internal/auth/throttle/throttle.go +++ b/internal/auth/throttle/throttle.go @@ -9,19 +9,23 @@ import ( "time" ) +// ErrThrottled means recent failures exceeded the configured bucket threshold. var ErrThrottled = errors.New("throttle: too many recent failures") +// Config controls the failed-auth rolling window and block duration. type Config struct { Window time.Duration Threshold uint32 Block time.Duration } +// Throttle records and evaluates failed-auth buckets. type Throttle struct { db *sql.DB cfg Config } +// New returns a throttle backed by db. func New(db *sql.DB, cfg Config) *Throttle { return &Throttle{db: db, cfg: cfg} } // Check inspects the recent failure count for the bucket; returns diff --git a/internal/automation/scene/applier.go b/internal/automation/scene/applier.go index 0530f2bd..435411a3 100644 --- a/internal/automation/scene/applier.go +++ b/internal/automation/scene/applier.go @@ -36,6 +36,7 @@ type Applier struct { metrics *observability.Metrics } +// NewApplier wires scene execution to config snapshots, command dispatch, events, and scripts. func NewApplier( snap SnapshotReader, dispatch action.CommandDispatcher, diff --git a/internal/carport/fakedriver/fakedriver.go b/internal/carport/fakedriver/fakedriver.go index ccf2cf83..817059e5 100644 --- a/internal/carport/fakedriver/fakedriver.go +++ b/internal/carport/fakedriver/fakedriver.go @@ -80,6 +80,7 @@ func (d *Double) Serve(t TB) (socketPath string, stop func()) { return socketPath, stop } +// Handshake implements the Carport driver handshake RPC for tests. func (d *Double) Handshake(_ context.Context, req *carportpb.HandshakeRequest) (*carportpb.HandshakeResponse, error) { if d.WantHandshakeError != nil { return nil, d.WantHandshakeError @@ -106,6 +107,7 @@ func (d *Double) Handshake(_ context.Context, req *carportpb.HandshakeRequest) ( }, nil } +// Run implements the bidirectional Carport command and event stream. func (d *Double) Run(srv carportpb.Driver_RunServer) error { // Emit any pre-programmed events immediately. for _, m := range d.EventsToEmit { @@ -140,10 +142,12 @@ func (d *Double) Run(srv carportpb.Driver_RunServer) error { } } +// Health reports the fake driver as healthy. func (d *Double) Health(_ context.Context, _ *carportpb.HealthRequest) (*carportpb.HealthResponse, error) { return &carportpb.HealthResponse{Ok: true}, nil } +// Shutdown marks the fake driver closed and acknowledges the request. func (d *Double) Shutdown(_ context.Context, _ *carportpb.ShutdownRequest) (*carportpb.ShutdownResponse, error) { d.mu.Lock() d.closed = true diff --git a/internal/commandcatalog/registry.go b/internal/commandcatalog/registry.go index a35baaf2..812c9c12 100644 --- a/internal/commandcatalog/registry.go +++ b/internal/commandcatalog/registry.go @@ -16,10 +16,19 @@ import ( type ArgType int const ( - ArgTypeString ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_STRING) - ArgTypeInt ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_INT) - ArgTypeBool ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_BOOL) - ArgTypeDuration ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_DURATION) + // ArgTypeString accepts a single string value. + ArgTypeString ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_STRING) + + // ArgTypeInt accepts a base-10 integer value. + ArgTypeInt ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_INT) + + // ArgTypeBool accepts a boolean value. + ArgTypeBool ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_BOOL) + + // ArgTypeDuration accepts a Go-style duration string. + ArgTypeDuration ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_DURATION) + + // ArgTypeStringList accepts a repeated string value. ArgTypeStringList ArgType = ArgType(catalogv1.ArgType_ARG_TYPE_STRING_LIST) ) diff --git a/internal/compute/doc.go b/internal/compute/doc.go new file mode 100644 index 00000000..09822aad --- /dev/null +++ b/internal/compute/doc.go @@ -0,0 +1,5 @@ +// Package compute reserves the service boundary for computed entity evaluation. +// +// The current implementation is deliberately small and returns an unsupported +// result until the runtime contract is promoted beyond the prototype surface. +package compute diff --git a/internal/diagnostics/doc.go b/internal/diagnostics/doc.go new file mode 100644 index 00000000..09ffa769 --- /dev/null +++ b/internal/diagnostics/doc.go @@ -0,0 +1,7 @@ +// Package diagnostics builds downloadable support bundles for operators. +// +// A bundle is a deterministic zip archive containing build metadata, health +// state, recent events, projection cursors, metrics, a redacted config snapshot, +// and goroutine stacks. The package does not collect live data itself; callers +// pass already-authorized inputs through Options. +package diagnostics diff --git a/internal/eventstore/filter.go b/internal/eventstore/filter.go index 1f9bb1de..a25bc016 100644 --- a/internal/eventstore/filter.go +++ b/internal/eventstore/filter.go @@ -17,6 +17,7 @@ type Filter struct { MinTs, MaxTs time.Time } +// Matches reports whether e satisfies every populated filter field. func (f Filter) Matches(e Event) bool { if len(f.Kinds) > 0 && !containsString(f.Kinds, e.Kind) { return false diff --git a/internal/eventstore/projector.go b/internal/eventstore/projector.go index 04ca4192..52e31fc2 100644 --- a/internal/eventstore/projector.go +++ b/internal/eventstore/projector.go @@ -6,10 +6,14 @@ import ( "github.com/fdatoo/switchyard/internal/storage" ) +// ProjectorMode controls whether a projector runs in the append transaction or asynchronously. type ProjectorMode int const ( + // ProjectorModeSync runs the projector inside the append transaction. ProjectorModeSync ProjectorMode = iota + + // ProjectorModeAsync runs the projector from the event tailer after commit. ProjectorModeAsync ) @@ -46,5 +50,8 @@ type Discarder interface { // from projection_cursors". type NoSnapshot struct{} -func (NoSnapshot) Snapshot(context.Context, storage.Tx) error { return nil } +// Snapshot is a no-op for SQL-backed projectors. +func (NoSnapshot) Snapshot(context.Context, storage.Tx) error { return nil } + +// Restore tells the store to resume from the durable projection cursor. func (NoSnapshot) Restore(context.Context, storage.Tx) (uint64, error) { return 0, nil } diff --git a/internal/eventstore/query.go b/internal/eventstore/query.go index 89aa579f..bf553060 100644 --- a/internal/eventstore/query.go +++ b/internal/eventstore/query.go @@ -11,6 +11,7 @@ import ( eventv1 "github.com/fdatoo/switchyard/gen/switchyard/event/v1" ) +// QueryOptions bounds and filters a historical event query. type QueryOptions struct { FromPosition uint64 ToPosition uint64 diff --git a/internal/eventstore/store.go b/internal/eventstore/store.go index d26f570b..631bb3d0 100644 --- a/internal/eventstore/store.go +++ b/internal/eventstore/store.go @@ -18,6 +18,7 @@ import ( "github.com/fdatoo/switchyard/internal/observability" ) +// Config controls eventstore snapshot cadence and subscriber buffering. type Config struct { SnapshotEveryEvents int SnapshotEveryPeriod time.Duration @@ -45,6 +46,7 @@ type projectorReg struct { mode ProjectorMode } +// Store is the append-only event log plus projector and subscription coordinator. type Store struct { cfg Config db *sql.DB @@ -85,6 +87,7 @@ func Open(ctx context.Context, cfg Config, db *sql.DB, logger *slog.Logger, metr return s, nil } +// RegisterProjector adds a projector before the store starts. func (s *Store) RegisterProjector(p Projector, mode ProjectorMode) error { if s.started.Load() { return errors.New("RegisterProjector: already started") @@ -107,6 +110,7 @@ func (s *Store) ProjectorNames() []string { return names } +// LatestPosition returns the highest committed event position known to the store. func (s *Store) LatestPosition() uint64 { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/eventstore/subscribe.go b/internal/eventstore/subscribe.go index efda7bdd..8a58e2a2 100644 --- a/internal/eventstore/subscribe.go +++ b/internal/eventstore/subscribe.go @@ -12,6 +12,7 @@ import ( "github.com/fdatoo/switchyard/internal/observability" ) +// SubscribeOptions controls live event delivery and optional durable catchup. type SubscribeOptions struct { FromPosition uint64 Filter Filter @@ -20,6 +21,7 @@ type SubscribeOptions struct { ChannelBuffer int } +// Subscription is a live stream of events from the store. type Subscription interface { C() <-chan Event Ack(position uint64) error @@ -27,6 +29,7 @@ type Subscription interface { Stats() SubscriptionStats } +// SubscriptionStats reports delivery counters and current buffer pressure. type SubscriptionStats struct { Delivered uint64 Dropped uint64 @@ -131,6 +134,7 @@ func (sub *subscriber) Stats() SubscriptionStats { } } +// Subscribe returns a live event subscription, optionally after durable catchup. func (s *Store) Subscribe(ctx context.Context, opts SubscribeOptions) (Subscription, error) { if !s.started.Load() { return nil, errors.New("Subscribe: store not started") diff --git a/internal/interestingness/pipeline_test.go b/internal/interestingness/pipeline_test.go index a1bd534a..d4af4c86 100644 --- a/internal/interestingness/pipeline_test.go +++ b/internal/interestingness/pipeline_test.go @@ -3,6 +3,7 @@ package interestingness_test import ( "bytes" "context" + "errors" "log/slog" "testing" "time" @@ -29,6 +30,14 @@ func newTestStore(t *testing.T) *eventstore.Store { return s } +func stopPipeline(t *testing.T, cancel context.CancelFunc, done <-chan error) { + t.Helper() + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + require.NoError(t, err) + } +} + // failureEvent builds an eventstore.Event of kind "cmd.failed". func failureEvent() eventstore.Event { return eventstore.Event{ @@ -60,6 +69,14 @@ func TestPipeline_AppendsTwoTaggedEventsForTwoFailures(t *testing.T) { t.Fatalf("store.Start: %v", err) } + // Append 3 events: 2 failures + 1 normal. + _, err := store.Append(ctx, failureEvent()) + require.NoError(t, err) + _, err = store.Append(ctx, nonInterestingEvent("light/living")) + require.NoError(t, err) + _, err = store.Append(ctx, failureEvent()) + require.NoError(t, err) + // Only the FailureDetector so we get deterministic tagged-event counts. detectors := []interestingness.Detector{ interestingness.NewFailureDetector(), @@ -75,25 +92,20 @@ func TestPipeline_AppendsTwoTaggedEventsForTwoFailures(t *testing.T) { go func() { pipelineDone <- pipeline.Start(pipelineCtx) }() + defer stopPipeline(t, pipelineCancel, pipelineDone) - // Append 3 events: 2 failures + 1 normal. - _, err := store.Append(ctx, failureEvent()) - require.NoError(t, err) - _, err = store.Append(ctx, nonInterestingEvent("light/living")) - require.NoError(t, err) - _, err = store.Append(ctx, failureEvent()) - require.NoError(t, err) - + var queryErr error require.Eventually(t, func() bool { events, err := store.Query(ctx, eventstore.QueryOptions{ Filter: eventstore.Filter{Kinds: []string{"interestingness.tagged"}}, }) - require.NoError(t, err) + if err != nil { + queryErr = err + return false + } return len(events) == 2 }, 5*time.Second, 25*time.Millisecond, "expected exactly 2 interestingness.tagged events") - - pipelineCancel() - <-pipelineDone + require.NoError(t, queryErr) } // TestPipeline_NoTagsForNonInterestingEvents verifies that events not matching @@ -107,6 +119,12 @@ func TestPipeline_NoTagsForNonInterestingEvents(t *testing.T) { t.Fatalf("store.Start: %v", err) } + // Only non-interesting events. + for i := 0; i < 3; i++ { + _, err := store.Append(ctx, nonInterestingEvent("switch/garage")) + require.NoError(t, err) + } + detectors := []interestingness.Detector{ interestingness.NewFailureDetector(), } @@ -118,16 +136,9 @@ func TestPipeline_NoTagsForNonInterestingEvents(t *testing.T) { pipelineCtx, pipelineCancel := context.WithCancel(ctx) pipelineDone := make(chan error, 1) go func() { pipelineDone <- pipeline.Start(pipelineCtx) }() - - // Only non-interesting events. - for i := 0; i < 3; i++ { - _, err := store.Append(ctx, nonInterestingEvent("switch/garage")) - require.NoError(t, err) - } + defer stopPipeline(t, pipelineCancel, pipelineDone) time.Sleep(200 * time.Millisecond) - pipelineCancel() - <-pipelineDone events, err := store.Query(ctx, eventstore.QueryOptions{ Filter: eventstore.Filter{Kinds: []string{"interestingness.tagged"}}, diff --git a/internal/observability/context.go b/internal/observability/context.go index c966512b..0468b3ca 100644 --- a/internal/observability/context.go +++ b/internal/observability/context.go @@ -7,10 +7,12 @@ import ( type ctxKey struct{} +// WithLogger returns a child context carrying logger. func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { return context.WithValue(ctx, ctxKey{}, logger) } +// LoggerFrom returns the request logger from ctx or slog.Default. func LoggerFrom(ctx context.Context) *slog.Logger { if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil { return l @@ -20,10 +22,12 @@ func LoggerFrom(ctx context.Context) *slog.Logger { type requestIDKey struct{} +// WithRequestID returns a child context carrying an externally visible request id. func WithRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, requestIDKey{}, id) } +// RequestIDFromContext returns the request id added by listener middleware. func RequestIDFromContext(ctx context.Context) (string, bool) { id, ok := ctx.Value(requestIDKey{}).(string) return id, ok diff --git a/internal/observability/logging.go b/internal/observability/logging.go index d9beb0a8..025f26eb 100644 --- a/internal/observability/logging.go +++ b/internal/observability/logging.go @@ -9,12 +9,14 @@ import ( charmlog "github.com/charmbracelet/log" ) +// LogConfig selects the daemon logger's format, level, and output stream. type LogConfig struct { Level slog.Level Format string // "auto" | "tty" | "json" Output io.Writer } +// Init builds a structured logger for daemon and CLI processes. func Init(cfg LogConfig) *slog.Logger { if cfg.Output == nil { cfg.Output = os.Stderr diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index e17461f4..252298ef 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -4,6 +4,7 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +// Metrics groups every Prometheus collector registered by the daemon. type Metrics struct { Registry *prometheus.Registry @@ -110,6 +111,7 @@ type Metrics struct { PolicyAuthorizeDurationSeconds prometheus.Histogram } +// NewMetrics creates an isolated registry and registers all daemon collectors. func NewMetrics() *Metrics { reg := prometheus.NewRegistry() m := &Metrics{Registry: reg} @@ -445,6 +447,7 @@ func NewMetrics() *Metrics { return m } +// SetBuildInfo publishes immutable build metadata as a Prometheus gauge label set. func (m *Metrics) SetBuildInfo(version, commit, goVersion string) { m.BuildInfo.WithLabelValues(version, commit, goVersion).Set(1) } diff --git a/internal/observability/metrics_server.go b/internal/observability/metrics_server.go index efb9d742..7cd96081 100644 --- a/internal/observability/metrics_server.go +++ b/internal/observability/metrics_server.go @@ -10,6 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) +// HTTPHandler returns the Prometheus scrape handler for this metrics registry. func (m *Metrics) HTTPHandler() http.Handler { return promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{}) } diff --git a/internal/observability/tracing.go b/internal/observability/tracing.go index a921a567..b149050e 100644 --- a/internal/observability/tracing.go +++ b/internal/observability/tracing.go @@ -21,6 +21,7 @@ func (noopSpan) SetAttr(string, any) {} func (noopSpan) AddEvent(string, ...any) {} func (noopSpan) RecordError(error) {} +// SpanStarter starts a tracing span and returns the context that should carry it. type SpanStarter func(ctx context.Context, name string) (context.Context, Span) type spanContextKey struct{} @@ -34,6 +35,7 @@ func startNoopSpan(ctx context.Context, _ string) (context.Context, Span) { return ctx, noopSpan{} } +// StartSpan starts a span using the configured starter and stores it in context. func StartSpan(ctx context.Context, name string) (context.Context, Span) { spanStarterMu.RLock() start := spanStarter @@ -45,6 +47,7 @@ func StartSpan(ctx context.Context, name string) (context.Context, Span) { return context.WithValue(ctx, spanContextKey{}, span), span } +// SpanFromContext returns the span previously attached by StartSpan. func SpanFromContext(ctx context.Context) (Span, bool) { span, ok := ctx.Value(spanContextKey{}).(Span) if !ok || span == nil { @@ -53,6 +56,7 @@ func SpanFromContext(ctx context.Context) (Span, bool) { return span, true } +// SetSpanStarterForTest replaces the span starter and returns a restore function. func SetSpanStarterForTest(start SpanStarter) func() { if start == nil { start = startNoopSpan diff --git a/internal/page/pklfs/doc.go b/internal/page/pklfs/doc.go new file mode 100644 index 00000000..41ca57a1 --- /dev/null +++ b/internal/page/pklfs/doc.go @@ -0,0 +1,6 @@ +// Package pklfs implements the filesystem-backed custom page store. +// +// Page definitions are Pkl files under the config directory. This package +// evaluates page source, tracks optional generated layout files, writes layout +// updates atomically, and exposes the storage backend used by the page service. +package pklfs diff --git a/internal/page/regen/doc.go b/internal/page/regen/doc.go new file mode 100644 index 00000000..7fcc4d9b --- /dev/null +++ b/internal/page/regen/doc.go @@ -0,0 +1,5 @@ +// Package regen renders deterministic Pkl layout files for custom pages. +// +// The renderer is used after UI edits so generated layout fragments remain +// stable, reviewable, and suitable for committing back to configuration. +package regen diff --git a/internal/pkllsp/doc.go b/internal/pkllsp/doc.go new file mode 100644 index 00000000..38981827 --- /dev/null +++ b/internal/pkllsp/doc.go @@ -0,0 +1,6 @@ +// Package pkllsp exposes Pkl language-server features through Connect-RPC. +// +// It keeps a single Pkl language-server subprocess per service, translates +// completion, hover, definition, diagnostics, and semantic-token requests, and +// owns the lifecycle rules needed to start and stop that subprocess cleanly. +package pkllsp diff --git a/internal/pkllsp/service.go b/internal/pkllsp/service.go index 32b933ba..d9c2cb64 100644 --- a/internal/pkllsp/service.go +++ b/internal/pkllsp/service.go @@ -17,6 +17,7 @@ import ( var _ pkllspv1connect.PklLsServiceHandler = (*Service)(nil) +// Config locates the Pkl language server and project roots. type Config struct { BinaryPath string ConfigDir string @@ -24,6 +25,7 @@ type Config struct { Logger *slog.Logger } +// Service adapts Pkl LSP requests into the Switchyard Connect-RPC API. type Service struct { cfg Config @@ -31,10 +33,12 @@ type Service struct { client *client } +// NewService returns a lazy-starting Pkl language service. func NewService(cfg Config) *Service { return &Service{cfg: cfg} } +// Close stops the backing Pkl language-server process if it is running. func (s *Service) Close(ctx context.Context) error { s.mu.Lock() c := s.client @@ -46,6 +50,7 @@ func (s *Service) Close(ctx context.Context) error { return c.close(ctx) } +// Complete returns completion candidates for the supplied in-memory Pkl document. func (s *Service) Complete(ctx context.Context, req *connect.Request[pkllsppb.CompleteRequest]) (*connect.Response[pkllsppb.CompleteResponse], error) { c, err := s.ensureClient(ctx) if err != nil { @@ -75,6 +80,7 @@ func (s *Service) Complete(ctx context.Context, req *connect.Request[pkllsppb.Co return connect.NewResponse(&pkllsppb.CompleteResponse{Items: out}), nil } +// Hover returns markdown documentation for the symbol at the requested position. func (s *Service) Hover(ctx context.Context, req *connect.Request[pkllsppb.HoverRequest]) (*connect.Response[pkllsppb.HoverResponse], error) { c, err := s.ensureClient(ctx) if err != nil { @@ -94,6 +100,7 @@ func (s *Service) Hover(ctx context.Context, req *connect.Request[pkllsppb.Hover return connect.NewResponse(&pkllsppb.HoverResponse{Markdown: markdownFromHover(raw)}), nil } +// Definition resolves the source location for the symbol at the requested position. func (s *Service) Definition(ctx context.Context, req *connect.Request[pkllsppb.DefinitionRequest]) (*connect.Response[pkllsppb.DefinitionResponse], error) { c, err := s.ensureClient(ctx) if err != nil { @@ -121,6 +128,7 @@ func (s *Service) Definition(ctx context.Context, req *connect.Request[pkllsppb. }), nil } +// Diagnose returns language-server diagnostics after syncing the in-memory document. func (s *Service) Diagnose(ctx context.Context, req *connect.Request[pkllsppb.DiagnoseRequest]) (*connect.Response[pkllsppb.DiagnoseResponse], error) { c, err := s.ensureClient(ctx) if err != nil { @@ -151,6 +159,7 @@ func (s *Service) Diagnose(ctx context.Context, req *connect.Request[pkllsppb.Di return connect.NewResponse(&pkllsppb.DiagnoseResponse{Diagnostics: out}), nil } +// SemanticTokens returns LSP semantic-token data for syntax highlighting. func (s *Service) SemanticTokens(ctx context.Context, req *connect.Request[pkllsppb.SemanticTokensRequest]) (*connect.Response[pkllsppb.SemanticTokensResponse], error) { c, err := s.ensureClient(ctx) if err != nil { diff --git a/internal/push/doc.go b/internal/push/doc.go new file mode 100644 index 00000000..f9a65d97 --- /dev/null +++ b/internal/push/doc.go @@ -0,0 +1,6 @@ +// Package push implements web-push subscription storage and notification fanout. +// +// The current store is in-memory and the notifier is policy-light: callers feed +// interesting events into the package, which resolves a user's subscriptions and +// sends only events above the configured severity threshold. +package push diff --git a/internal/registry/migrations/doc.go b/internal/registry/migrations/doc.go new file mode 100644 index 00000000..bd37c697 --- /dev/null +++ b/internal/registry/migrations/doc.go @@ -0,0 +1,2 @@ +// Package migrations embeds registry SQL migrations for goose. +package migrations diff --git a/internal/registry/migrations/migrations.go b/internal/registry/migrations/migrations.go index 91cca1c3..2d5cdb77 100644 --- a/internal/registry/migrations/migrations.go +++ b/internal/registry/migrations/migrations.go @@ -2,5 +2,7 @@ package migrations import "embed" +// FS contains registry schema migrations. +// //go:embed *.sql var FS embed.FS diff --git a/internal/starlark/doc.go b/internal/starlark/doc.go new file mode 100644 index 00000000..783a22dc --- /dev/null +++ b/internal/starlark/doc.go @@ -0,0 +1,7 @@ +// Package starlark is the sandboxed runtime for Switchyard scripts. +// +// It parses expressions and scripts, loads relative modules from the config +// tree, injects Switchyard builtins, enforces wall-clock and step limits, and +// returns structured results that automation and computed-entity callers can +// inspect without depending on raw Starlark values. +package starlark diff --git a/internal/starlark/testutil/testutil.go b/internal/starlark/testutil/testutil.go index a3252232..5a244b99 100644 --- a/internal/starlark/testutil/testutil.go +++ b/internal/starlark/testutil/testutil.go @@ -18,6 +18,7 @@ import ( // FakeState maps entity ID → EntityState for test injection. type FakeState map[string]*ghs.EntityState +// Get returns the fake entity state by id. func (f FakeState) Get(id string) (*ghs.EntityState, bool) { v, ok := f[id] return v, ok @@ -36,6 +37,7 @@ type FakeDispatcher struct { Calls []DispatchCall } +// Dispatch records a successful fake service call. func (f *FakeDispatcher) Dispatch(_ context.Context, entityID, capability string, args map[string]string) (*ghs.DispatchResult, error) { f.mu.Lock() f.Calls = append(f.Calls, DispatchCall{EntityID: entityID, Capability: capability, Args: args}) diff --git a/internal/storage/lockfile.go b/internal/storage/lockfile.go index e9691e39..52a26524 100644 --- a/internal/storage/lockfile.go +++ b/internal/storage/lockfile.go @@ -38,6 +38,7 @@ func AcquireLockfile(dataDir string) (*Lockfile, error) { return &Lockfile{path: path}, nil } +// Release removes the PID lockfile if this process still owns its path. func (l *Lockfile) Release() error { if l == nil { return nil diff --git a/internal/storage/migrations/migrations.go b/internal/storage/migrations/migrations.go index 32be0845..5c55689c 100644 --- a/internal/storage/migrations/migrations.go +++ b/internal/storage/migrations/migrations.go @@ -3,5 +3,7 @@ package migrations import "embed" +// FS contains eventstore schema migrations. +// //go:embed *.sql var FS embed.FS diff --git a/internal/storage/open.go b/internal/storage/open.go index 03b6ad60..bc6d94df 100644 --- a/internal/storage/open.go +++ b/internal/storage/open.go @@ -13,6 +13,7 @@ import ( eventMigrations "github.com/fdatoo/switchyard/internal/storage/migrations" ) +// Config identifies the SQLite database to open. type Config struct { Path string // absolute path to .db file; use ":memory:" for tests } diff --git a/internal/testutil/events.go b/internal/testutil/events.go index f2d89bcb..69c65ef2 100644 --- a/internal/testutil/events.go +++ b/internal/testutil/events.go @@ -10,15 +10,24 @@ import ( "github.com/fdatoo/switchyard/internal/eventstore" ) +// EventOption mutates a synthesized test event. type EventOption func(*eventstore.Event) +// WithSource overrides the event source. func WithSource(s string) EventOption { return func(e *eventstore.Event) { e.Source = s } } + +// WithCorrelation overrides the event correlation id. func WithCorrelation(id uuid.UUID) EventOption { return func(e *eventstore.Event) { e.CorrelationID = id } } -func WithCause(pos uint64) EventOption { return func(e *eventstore.Event) { e.CausePosition = pos } } + +// WithCause sets the event cause position. +func WithCause(pos uint64) EventOption { return func(e *eventstore.Event) { e.CausePosition = pos } } + +// WithTimestamp overrides the event timestamp. func WithTimestamp(t time.Time) EventOption { return func(e *eventstore.Event) { e.Timestamp = t } } +// StateChanged builds a light state_changed event for tests. func StateChanged(entity string, brightness uint32, opts ...EventOption) eventstore.Event { e := eventstore.Event{ Kind: "state_changed", @@ -41,6 +50,7 @@ func StateChanged(entity string, brightness uint32, opts ...EventOption) eventst return e } +// SystemStartup builds a system startup event for tests. func SystemStartup(opts ...EventOption) eventstore.Event { e := eventstore.Event{ Kind: "system", diff --git a/internal/web/doc.go b/internal/web/doc.go new file mode 100644 index 00000000..0c671ef1 --- /dev/null +++ b/internal/web/doc.go @@ -0,0 +1,6 @@ +// Package web serves the embedded Switchyard app and widget assets. +// +// It owns HTTP fallback routing for the single-page app, immutable cache +// headers for built assets, lightweight health checks, and widget-pack file +// serving. API traffic is handled by the Connect listener, not this package. +package web diff --git a/internal/web/embed.go b/internal/web/embed.go index bff6d138..6bf25ea5 100644 --- a/internal/web/embed.go +++ b/internal/web/embed.go @@ -2,5 +2,7 @@ package web import "embed" +// Assets contains the built web UI bundle embedded into switchyardd. +// //go:embed all:dist var Assets embed.FS