diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..15461d0b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab + +[*.{pkl,ts,vue}] +indent_size = 2 + +[*.md] +indent_size = 2 +trim_trailing_whitespace = false + +[Taskfile.yml] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..003087b3 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +msg_file="$1" +subject="$(sed -n '1p' "$msg_file")" + +case "$subject" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *) + exit 0 + ;; +esac + +line_count="$(grep -v '^[[:space:]]*#' "$msg_file" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')" +if [ "$line_count" -ne 1 ]; then + echo "commit-msg: use exactly one non-empty line" >&2 + exit 1 +fi + +pattern='^(feat|fix|chore|refactor|test|docs|perf|build)\((app|api|auth|carport|cli|config|daemon|driverkit|drivers|eventstore|policy|proto|repo|ci|docs|agents)\): [a-z0-9][a-z0-9 ,'\''`._:/()+-]*[^.]$' +if ! printf '%s\n' "$subject" | grep -Eq "$pattern"; then + echo "commit-msg: expected '(): '" >&2 + echo "commit-msg: example: feat(config): add semantic apply messages" >&2 + exit 1 +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..7ae6669c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,37 @@ +#!/bin/sh +set -eu + +cd "$(git rev-parse --show-toplevel)" + +staged="$(git diff --cached --name-only --diff-filter=ACMR)" + +if printf '%s\n' "$staged" | grep -Eq '\.go$'; then + echo "pre-commit: checking Go formatting" + task fmt-check + + if command -v golangci-lint >/dev/null 2>&1; then + echo "pre-commit: running Go lint" + task lint + else + echo "pre-commit: golangci-lint not found; CI will lint" + fi +fi + +if printf '%s\n' "$staged" | grep -Eq '\.proto$'; then + if command -v buf >/dev/null 2>&1; then + echo "pre-commit: checking generated proto" + PATH="$PATH:$(go env GOPATH)/bin" task proto + git diff --exit-code gen/ + else + echo "pre-commit: buf not found; CI will verify proto generation" + fi +fi + +if printf '%s\n' "$staged" | grep -Eq '\.pkl$'; then + if command -v pkl >/dev/null 2>&1; then + echo "pre-commit: checking Pkl config evaluation" + go test -tags=integration -run TestEvaluator_ValidConfig ./internal/config/ + else + echo "pre-commit: pkl not found; CI will run integration coverage" + fi +fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..1feeb657 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +cd "$(git rev-parse --show-toplevel)" + +echo "pre-push: unit tests" +task test + +echo "pre-push: race tests" +task test:race + +echo "pre-push: integration tests" +if command -v pkl >/dev/null 2>&1; then + task test:integration +else + echo "pre-push: pkl not found; skipping config integration package" + go test -tags=integration $(go list -tags=integration ./... | grep -v 'internal/config') +fi diff --git a/.gitignore b/.gitignore index 15bce236..01326d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ # Go build artifacts dist/ bin/ -/gohomed -/gohome +/switchyardd +/switchyard *.exe *.test *.out @@ -14,8 +14,8 @@ var/ *.db *.db-shm *.db-wal -/gohomed.lock -/gohomed.sock +/switchyardd.lock +/switchyardd.sock # Embedded web asset build outputs; keep the placeholder committed. !internal/web/dist/ @@ -24,6 +24,8 @@ internal/web/dist/* # Docs build output docs/site/ +docs/.cache/ +docs/.venv/ # Editor .idea/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1d1982de --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,126 @@ +# Working in Switchyard + +This is the canonical repo-onboarding doc. `CLAUDE.md` is a symlink to it, so +tools that look for `AGENTS.md` and tools that look for `CLAUDE.md` read the +same content. + +If you are a human, this doc still applies. + +## Setup + +First run `task setup`. Skipping this means your commits will likely be +rejected. This activates the mandatory Git hooks via `core.hooksPath`. + +A clean clone also needs Go 1.25+, Node.js 20+, Task, Buf, Pkl, and +golangci-lint. Standard commands: + +| Action | Command | +|--------------|-------------------------| +| Setup | `task setup` | +| Build | `task build` | +| Test | `task test` | +| Race test | `task test:race` | +| Integration | `task test:integration` | +| Format | `task fmt` | +| Format check | `task fmt-check` | +| Lint | `task lint` | +| Check | `task check` | +| Full check | `task check:full` | + +Run `task check` before claiming broad repo work is done. Run +`task check:full` when touching runtime concurrency, integration surfaces, or +release-critical paths. For narrow changes, run the smallest relevant subset +and say exactly what passed. + +## Repo Layout + +``` +Switchyard/ +|-- AGENTS.md # this file (CLAUDE.md is a symlink to it) +|-- Taskfile.yml # setup, build, test, fmt, lint, check +|-- app/ # Vue admin UI +|-- cmd/ # switchyardd, switchyard, and test binaries +|-- docs/ # documentation site and long-lived notes +|-- docs/agents/ # agent-authored working docs +| |-- specs/ # design specs: what and why +| `-- plans/ # implementation plans: how +|-- docs/adrs/ # architecture decision records +|-- drivers/ # first-party out-of-process drivers +|-- examples/ # sample config files and config fragments +|-- gen/ # committed generated protobuf code +|-- internal/ # main daemon and CLI internals +|-- proto/ # protobuf API and event schemas +|-- switchyard-driverkit/ # public Go driver SDK module +`-- testdata/ # golden fixtures and integration data +``` + +Rules: + +- Product docs and long-lived architecture notes go in `docs/`. +- Agent-authored specs and plans go in `docs/agents/`. +- Cross-cutting durable decisions go in `docs/adrs/`. +- The root Go module stays at `.`. Do not move daemon code under a new top-level + module directory. +- Do not add a new top-level directory without first updating this layout and + explaining why the existing boundaries do not fit. + +## Code Conventions + +- **Go:** use Go 1.25+. Keep package APIs explicit and small. Prefer standard + library types unless an existing local abstraction already owns the invariant. +- **Errors:** do not silence errors. Return explicit errors with useful context. + Use sentinel or typed errors when callers need to branch. Do not string-match + errors unless the dependency leaves no better option, and document that case. +- **Logging:** use structured logging. Log and return/propagate; logging is not + a substitute for handling an error. +- **Config:** Pkl is the source config language. Generated config snapshots are + protobuf artifacts. Config validation errors should include a stable code + where practical, plus file/line/field context when known. +- **Proto:** preserve field numbers, reserve removed fields, and run + `task proto` after schema changes. Generated code under `gen/` is committed. +- **Frontend:** keep UI code in `app/`. Use `task app:typecheck`, + `task app:test`, and `task app:build` for app-only verification. +- **Comments:** explain constraints and surprising decisions. Do not narrate + obvious control flow. + +## Semantic Messages + +Commit messages are semantic, one line maximum. No body. No trailers. + +``` +feat(config): add semantic apply messages +fix(app): preserve scene filter on reload +chore(ci): cache app dependencies +docs(agents): document repo workflow +``` + +- **Allowed prefixes:** `feat`, `fix`, `chore`, `refactor`, `test`, `docs`, + `perf`, `build`. +- **Allowed scopes:** `app`, `api`, `auth`, `carport`, `cli`, `config`, + `daemon`, `driverkit`, `drivers`, `eventstore`, `policy`, `proto`, `repo`, + `ci`, `docs`, `agents`. +- **Subject:** imperative, lowercase, no trailing period. + +Config apply messages use the same one-line shape and the `config` prefix: + +``` +config(scene): add evening kitchen scene +config(area): move office devices +config(driver): rotate hue bridge credentials +``` + +Allowed config scopes are `area`, `automation`, `driver`, `entity`, `page`, +`policy`, `scene`, `script`, `auth`, `mcp`, and `repo`. + +Never include `Co-Authored-By:` trailers, generated-by footers, tool +watermarks, or agent attribution. + +## Workflow Expectations + +1. Read the issue or local spec first. +2. Write or update a spec under `docs/agents/specs/` before non-trivial design + work. +3. Write or update a plan under `docs/agents/plans/` for multi-step execution. +4. Keep patches surgical. Do not refactor unrelated code. +5. Verify locally before claiming done. Treat local failures the same way CI + will. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7713e228..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,21 +0,0 @@ -# Switchyard — Monorepo - -## Directory map - -| Path | Module | Purpose | -|------|--------|---------| -| `.` | `github.com/fdatoo/switchyard` | `switchyardd` daemon + `switchyard` CLI | -| `switchyard-driverkit/` | `github.com/fdatoo/switchyard-driverkit` | Driver development kit | -| `docs/` | — | Documentation site (Zensical) | -| `docs/design/` | — | Design specs and implementation plans | -| `dev/` | — | Internal developer notes (proto hygiene, setup guides) | - -## Go workspace - -`go.work` at the repo root links `.` and `./switchyard-driverkit`. Use standard `go` commands from anywhere in the tree; the workspace resolves both modules locally. - -## Rules - -- **Documentation and design specs live in `docs/design/`**, not scattered in the source tree. -- **Never create a new top-level directory** without checking with the user first. -- The `github.com/fdatoo/switchyard` module root is the repo root (`.`), not a subdirectory. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 02c81a3c..b8987c6e 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,17 @@ Both modules are linked by a Go workspace (`go.work`), so `go build ./...` and ` - [Pkl](https://pkl-lang.org) — `brew install pkl` (config schema) - Node.js 20+ — for the web UI +## Setup + +```bash +task setup # activates repo Git hooks +``` + ## Building ```bash task build # builds switchyardd + switchyard binaries into dist/ -task web:build # builds the web UI (required before task build) +task app:build # builds the web UI ``` ## Testing @@ -37,6 +43,8 @@ task web:build # builds the web UI (required before task build) task test # unit tests task test:race # race detector task test:integration # integration tests (real disk I/O) +task check # standard local verification suite +task check:full # standard checks plus race and integration tests ``` ## Drivers @@ -50,7 +58,10 @@ go build ./... ## Documentation -Full documentation lives in [`docs/`](./docs) and is published via Zensical. Design specs and implementation plans live in [`docs/design/`](./docs/design). +Full documentation lives in [`docs/`](./docs) and is published via Zensical. +Agent-authored specs and implementation plans live in +[`docs/agents/`](./docs/agents). Cross-cutting decisions live in +[`docs/adrs/`](./docs/adrs). ## License diff --git a/Taskfile.yml b/Taskfile.yml index e3b420a6..b8882ab3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -7,17 +7,47 @@ tasks: default: deps: [build] + setup: + desc: Configure local repository hooks + cmds: + - git config core.hooksPath .githooks + + fmt: + desc: Format Go sources + cmds: + - find cmd internal drivers switchyard-driverkit -name '*.go' -print | xargs gofmt -w + + fmt-check: + desc: Check Go source formatting + cmds: + - | + files="$(find cmd internal drivers switchyard-driverkit -name '*.go' -print)" + unformatted="$(gofmt -l $files)" + if [ -n "$unformatted" ]; then + printf '%s\n' "$unformatted" + exit 1 + fi + proto: desc: Regenerate protobuf code via buf cmds: - buf generate - gen:activity: - desc: Regenerate Go + TS bindings for activity/v1 proto only + proto:check: + internal: true + desc: Check generated protobuf code is in sync cmds: - - PATH="$(go env GOPATH)/bin:$PATH" buf generate --path proto/switchyard/activity/v1/ + - | + before="$(git diff -- gen)" + PATH="$(go env GOPATH)/bin:$PATH" buf generate + after="$(git diff -- gen)" + if [ "$before" != "$after" ]; then + echo "generated protobuf code changed; run task proto and keep gen/ in sync" >&2 + exit 1 + fi app:install: + internal: true desc: Install app npm dependencies (idempotent) dir: app cmds: @@ -97,3 +127,74 @@ tasks: desc: go mod tidy cmds: - go mod tidy + + tidy:check: + internal: true + desc: Check Go module files are tidy + cmds: + - | + before="$(git diff -- go.mod go.sum)" + go mod tidy + after="$(git diff -- go.mod go.sum)" + if [ "$before" != "$after" ]; then + echo "go.mod/go.sum changed after go mod tidy" >&2 + exit 1 + fi + + driverkit:build: + internal: true + desc: Build switchyard-driverkit + dir: switchyard-driverkit + cmds: + - go build ./... + + driverkit:test: + internal: true + desc: Test switchyard-driverkit + dir: switchyard-driverkit + cmds: + - go test ./... + + driverkit:lint: + internal: true + desc: Lint switchyard-driverkit + dir: switchyard-driverkit + cmds: + - golangci-lint run ./... + + driverkit:tidy:check: + internal: true + desc: Check switchyard-driverkit module files are tidy + dir: switchyard-driverkit + cmds: + - | + before="$(git diff -- go.mod go.sum)" + go mod tidy + after="$(git diff -- go.mod go.sum)" + if [ "$before" != "$after" ]; then + echo "switchyard-driverkit/go.mod or go.sum changed after go mod tidy" >&2 + exit 1 + fi + + check: + desc: Run the standard local verification suite + cmds: + - task: fmt-check + - task: proto:check + - task: tidy:check + - task: driverkit:tidy:check + - task: app:typecheck + - task: app:test + - task: build + - task: test + - task: driverkit:build + - task: driverkit:test + - task: lint + - task: driverkit:lint + + check:full: + desc: Run standard checks plus race and integration tests + cmds: + - task: check + - task: test:race + - task: test:integration diff --git a/cmd/switchyard/golden_test.go b/cmd/switchyard/golden_test.go index 794034e1..276afc9d 100644 --- a/cmd/switchyard/golden_test.go +++ b/cmd/switchyard/golden_test.go @@ -133,7 +133,7 @@ func TestCLIGoldenFixtures(t *testing.T) { {name: "driver-error", args: []string{"driver", "restart", "missing-driver"}}, {name: "config-validate", args: []string{"config", "validate"}}, {name: "config-validate-offline", args: []string{"config", "validate", "--offline"}}, - {name: "config-apply-dry-run", args: []string{"config", "apply", "--dry-run", "--message", "cli golden"}}, + {name: "config-apply-dry-run", args: []string{"config", "apply", "--dry-run", "--message", "config(repo): validate golden config"}}, {name: "config-reload", args: []string{"config", "reload"}}, {name: "config-error", args: []string{"config", "validate", "--offline", "--config-dir", filepath.Join(fx.dataDir, "missing-config")}}, {name: "eval", args: []string{"eval", filepath.Join(fx.dataDir, "config", "eval.star")}}, diff --git a/docs/adrs/.gitkeep b/docs/adrs/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/adrs/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/adrs/README.md b/docs/adrs/README.md new file mode 100644 index 00000000..7bc5f588 --- /dev/null +++ b/docs/adrs/README.md @@ -0,0 +1,35 @@ +# Architecture Decision Records + +This directory holds Architecture Decision Records for Switchyard. An ADR +captures one cross-cutting technical decision: the context that forced it, the +choice that was made, and the consequences that follow. + +## When To Write One + +Write an ADR when the decision: + +- spans more than one package, module, driver, API surface, or runtime process; +- is something a future contributor is likely to ask "why?" about; +- is hard or expensive to reverse, such as wire formats, persistence strategy, + config language semantics, auth model, or generated-code policy. + +Do not write an ADR for routine implementation choices that live inside one +package and can be changed without coordination. Those belong in code, in a +design spec under `docs/agents/specs/`, or in the PR description. + +## Format + +Use a short Nygard-style shape: + +- **Status:** `Proposed`, `Accepted`, or `Superseded by ADR-NNNN`. +- **Date:** ISO date the decision was accepted. +- **Context:** what forced the decision and the constraints. +- **Decision:** the choice, stated plainly. +- **Consequences:** what follows from the choice. + +Keep ADRs short. Long ADRs are usually two ADRs. + +## Naming + +Use `NNNN-short-slug.md`, where `NNNN` is a zero-padded four-digit number and +the slug describes the decision. diff --git a/docs/agents/README.md b/docs/agents/README.md new file mode 100644 index 00000000..dc31a9ea --- /dev/null +++ b/docs/agents/README.md @@ -0,0 +1,24 @@ +# Agent Artifacts + +This directory holds agent-authored planning artifacts so they do not pollute +the product and architecture docs in `docs/`. + +| Directory | Purpose | +|-----------|---------| +| `specs/` | Design specs, one per non-trivial issue or epic. The what and why. | +| `plans/` | Implementation plans for a spec. The how. | + +## Naming + +Use `YYYY-MM-DD-short-slug.md` with the UTC date the file was authored. + +## Lifecycle + +Specs and plans are working documents. They capture intent at the time they +were written and are not kept perfectly in sync with the code afterwards. +Architectural decisions that need to outlive the issue belong in `docs/adrs/`. + +## Boundary + +- `docs/`: product, user, operations, and long-lived architecture material. +- `docs/agents/`: agent-authored working docs tied to a specific task. diff --git a/docs/agents/plans/.gitkeep b/docs/agents/plans/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/agents/plans/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/agents/specs/.gitkeep b/docs/agents/specs/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/agents/specs/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/design/plans/2026-05-11-ui-v2-plan-06-custom-pages.md b/docs/design/plans/2026-05-11-ui-v2-plan-06-custom-pages.md index baf97550..4cee3a0b 100644 --- a/docs/design/plans/2026-05-11-ui-v2-plan-06-custom-pages.md +++ b/docs/design/plans/2026-05-11-ui-v2-plan-06-custom-pages.md @@ -124,8 +124,8 @@ internal/dashboard/pklfs/testdata/ → internal/page/pklfs/testdata/ (moved wit ### Sample data ``` -pages/energy-climate.pkl ← user-owned; title + section metadata -pages/energy-climate.layout.pkl ← regenerator-owned; canonical section tree +examples/pages/energy-climate.pkl ← user-owned; title + section metadata +examples/pages/energy-climate.layout.pkl ← regenerator-owned; canonical section tree ``` --- @@ -338,7 +338,7 @@ The `PklPreview` sub-component inside `SettingsRail` calls the server-side `Page ## Task 6.14 — Sample page: `energy-climate` -**Files:** `pages/energy-climate.pkl` (user-owned), `pages/energy-climate.layout.pkl` (regen-owned) +**Files:** `examples/pages/energy-climate.pkl` (user-owned), `examples/pages/energy-climate.layout.pkl` (regen-owned) `energy-climate.pkl` imports `energy-climate.layout.pkl` and declares `page = new p.Page { slug = "energy-climate"; title = "Energy & Climate"; sections = layout.sections }`. diff --git a/docs/design/plans/2026-05-11-ui-v2-plan-07-displays-ambient.md b/docs/design/plans/2026-05-11-ui-v2-plan-07-displays-ambient.md index 1100a9a0..0b7dbf93 100644 --- a/docs/design/plans/2026-05-11-ui-v2-plan-07-displays-ambient.md +++ b/docs/design/plans/2026-05-11-ui-v2-plan-07-displays-ambient.md @@ -300,7 +300,7 @@ Thread `display.alert_threshold` (already fetched in Task 7.7) down to `/pages/` to use them with `PageService`. +- `displays/`: sample display config from the ambient-display design work. + The current `DisplayService` stores paired displays in the data directory, + so these are reference snippets rather than live repo-root config. diff --git a/displays/bedroom-nightstand.pkl b/examples/displays/bedroom-nightstand.pkl similarity index 100% rename from displays/bedroom-nightstand.pkl rename to examples/displays/bedroom-nightstand.pkl diff --git a/displays/kitchen-wall.pkl b/examples/displays/kitchen-wall.pkl similarity index 100% rename from displays/kitchen-wall.pkl rename to examples/displays/kitchen-wall.pkl diff --git a/pages/energy-climate.layout.pkl b/examples/pages/energy-climate.layout.pkl similarity index 100% rename from pages/energy-climate.layout.pkl rename to examples/pages/energy-climate.layout.pkl diff --git a/pages/energy-climate.pkl b/examples/pages/energy-climate.pkl similarity index 100% rename from pages/energy-climate.pkl rename to examples/pages/energy-climate.pkl diff --git a/gen/switchyard/event/v1/event.pb.go b/gen/switchyard/event/v1/event.pb.go index 3c194fb1..a0480801 100644 --- a/gen/switchyard/event/v1/event.pb.go +++ b/gen/switchyard/event/v1/event.pb.go @@ -876,12 +876,13 @@ func (x *DriverEvent) GetDetail() string { type ConfigApplied struct { state protoimpl.MessageState `protogen:"open.v1"` // 1-9: meta / payload - AppliedAtUnixMs int64 `protobuf:"varint,1,opt,name=applied_at_unix_ms,json=appliedAtUnixMs,proto3" json:"applied_at_unix_ms,omitempty"` - DriverInstancesAdded int32 `protobuf:"varint,2,opt,name=driver_instances_added,json=driverInstancesAdded,proto3" json:"driver_instances_added,omitempty"` - DriverInstancesRemoved int32 `protobuf:"varint,3,opt,name=driver_instances_removed,json=driverInstancesRemoved,proto3" json:"driver_instances_removed,omitempty"` - DriverInstancesChanged int32 `protobuf:"varint,4,opt,name=driver_instances_changed,json=driverInstancesChanged,proto3" json:"driver_instances_changed,omitempty"` - AutomationsChanged int32 `protobuf:"varint,5,opt,name=automations_changed,json=automationsChanged,proto3" json:"automations_changed,omitempty"` - DryRun bool `protobuf:"varint,6,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"` + AppliedAtUnixMs int64 `protobuf:"varint,1,opt,name=applied_at_unix_ms,json=appliedAtUnixMs,proto3" json:"applied_at_unix_ms,omitempty"` + DriverInstancesAdded int32 `protobuf:"varint,2,opt,name=driver_instances_added,json=driverInstancesAdded,proto3" json:"driver_instances_added,omitempty"` + DriverInstancesRemoved int32 `protobuf:"varint,3,opt,name=driver_instances_removed,json=driverInstancesRemoved,proto3" json:"driver_instances_removed,omitempty"` + DriverInstancesChanged int32 `protobuf:"varint,4,opt,name=driver_instances_changed,json=driverInstancesChanged,proto3" json:"driver_instances_changed,omitempty"` + AutomationsChanged int32 `protobuf:"varint,5,opt,name=automations_changed,json=automationsChanged,proto3" json:"automations_changed,omitempty"` + DryRun bool `protobuf:"varint,6,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"` + Message string `protobuf:"bytes,7,opt,name=message,proto3" json:"message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -958,6 +959,13 @@ func (x *ConfigApplied) GetDryRun() bool { return false } +func (x *ConfigApplied) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + type AutomationTriggered struct { state protoimpl.MessageState `protogen:"open.v1"` // 1-9: identity @@ -1903,14 +1911,15 @@ const file_switchyard_event_v1_event_proto_rawDesc = "" + "\vDriverEvent\x12,\n" + "\x12driver_instance_id\x18\x01 \x01(\tR\x10driverInstanceId\x12\x12\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x16\n" + - "\x06detail\x18\x03 \x01(\tR\x06detail\"\xb0\x02\n" + + "\x06detail\x18\x03 \x01(\tR\x06detail\"\xca\x02\n" + "\rConfigApplied\x12+\n" + "\x12applied_at_unix_ms\x18\x01 \x01(\x03R\x0fappliedAtUnixMs\x124\n" + "\x16driver_instances_added\x18\x02 \x01(\x05R\x14driverInstancesAdded\x128\n" + "\x18driver_instances_removed\x18\x03 \x01(\x05R\x16driverInstancesRemoved\x128\n" + "\x18driver_instances_changed\x18\x04 \x01(\x05R\x16driverInstancesChanged\x12/\n" + "\x13automations_changed\x18\x05 \x01(\x05R\x12automationsChanged\x12\x17\n" + - "\adry_run\x18\x06 \x01(\bR\x06dryRun\"\xd9\x01\n" + + "\adry_run\x18\x06 \x01(\bR\x06dryRun\x12\x18\n" + + "\amessage\x18\a \x01(\tR\amessage\"\xd9\x01\n" + "\x13AutomationTriggered\x12#\n" + "\rautomation_id\x18\x01 \x01(\tR\fautomationId\x12%\n" + "\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x124\n" + diff --git a/gen/switchyard/v1alpha1/config.pb.go b/gen/switchyard/v1alpha1/config.pb.go index 7e8daaa6..514b8c42 100644 --- a/gen/switchyard/v1alpha1/config.pb.go +++ b/gen/switchyard/v1alpha1/config.pb.go @@ -219,6 +219,7 @@ type ApplyConfigResponse struct { Diff *ConfigDiff `protobuf:"bytes,2,opt,name=diff,proto3" json:"diff,omitempty"` CorrelationId string `protobuf:"bytes,3,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` BundleHash string `protobuf:"bytes,4,opt,name=bundle_hash,json=bundleHash,proto3" json:"bundle_hash,omitempty"` + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -281,6 +282,13 @@ func (x *ApplyConfigResponse) GetBundleHash() string { return "" } +func (x *ApplyConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + type ReloadConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1010,13 +1018,14 @@ const file_switchyard_v1alpha1_config_proto_rawDesc = "" + "\adry_run\x18\n" + " \x01(\bR\x06dryRun\x12\x16\n" + "\x06strict\x18\v \x01(\bR\x06strict\x120\n" + - "\x14expected_bundle_hash\x18\f \x01(\tR\x12expectedBundleHash\"\xac\x01\n" + + "\x14expected_bundle_hash\x18\f \x01(\tR\x12expectedBundleHash\"\xc6\x01\n" + "\x13ApplyConfigResponse\x12\x18\n" + "\aapplied\x18\x01 \x01(\bR\aapplied\x123\n" + "\x04diff\x18\x02 \x01(\v2\x1f.switchyard.v1alpha1.ConfigDiffR\x04diff\x12%\n" + "\x0ecorrelation_id\x18\x03 \x01(\tR\rcorrelationId\x12\x1f\n" + "\vbundle_hash\x18\x04 \x01(\tR\n" + - "bundleHash\"\x15\n" + + "bundleHash\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\"\x15\n" + "\x13ReloadConfigRequest\"\x88\x01\n" + "\x14ReloadConfigResponse\x123\n" + "\x04diff\x18\x01 \x01(\v2\x1f.switchyard.v1alpha1.ConfigDiffR\x04diff\x12%\n" + diff --git a/internal/api/deps.go b/internal/api/deps.go index 5395da70..12ba27d4 100644 --- a/internal/api/deps.go +++ b/internal/api/deps.go @@ -226,6 +226,7 @@ type ConfigApplyResult struct { Diff ConfigDiff CorrelationID string BundleHash string + Message string Errors []string } diff --git a/internal/api/service_config.go b/internal/api/service_config.go index af3dedab..1a810e72 100644 --- a/internal/api/service_config.go +++ b/internal/api/service_config.go @@ -39,6 +39,7 @@ func (s *ConfigService) Apply(ctx context.Context, req *connect.Request[v1.Apply Diff: configDiffToProto(result.Diff), CorrelationId: result.CorrelationID, BundleHash: result.BundleHash, + Message: result.Message, }), nil } diff --git a/internal/api/service_config_test.go b/internal/api/service_config_test.go index fcdffec6..3712bc18 100644 --- a/internal/api/service_config_test.go +++ b/internal/api/service_config_test.go @@ -25,8 +25,9 @@ type fakeConfig struct { validateHash string validateErr error - applyResult api.ConfigApplyResult - applyErr error + applyResult api.ConfigApplyResult + applyErr error + applyMessage string reloadDiff api.ConfigDiff reloadCorrID string @@ -45,7 +46,8 @@ func (f *fakeConfig) Validate(_ context.Context, _ []byte) (bool, []string, api. return f.validateValid, f.validateErrs, f.validateDiff, f.validateHash, f.validateErr } -func (f *fakeConfig) Apply(_ context.Context, _ []byte, _, _ string, _, _ bool, _ string) (api.ConfigApplyResult, error) { +func (f *fakeConfig) Apply(_ context.Context, _ []byte, message, _ string, _, _ bool, _ string) (api.ConfigApplyResult, error) { + f.applyMessage = message return f.applyResult, f.applyErr } @@ -124,14 +126,18 @@ func TestConfigService_Apply(t *testing.T) { Applied: true, CorrelationID: "corr-1", BundleHash: "hash-1", + Message: "config(repo): validate golden config", }, } s := api.NewConfigService(fc) - resp, err := s.Apply(context.Background(), connect.NewRequest(&v1.ApplyConfigRequest{PklBundle: []byte("pkl"), Message: "test apply"})) + resp, err := s.Apply(context.Background(), connect.NewRequest(&v1.ApplyConfigRequest{PklBundle: []byte("pkl"), Message: "config(repo): validate golden config"})) if err != nil { t.Fatalf("err: %v", err) } - if !resp.Msg.Applied || resp.Msg.CorrelationId != "corr-1" || resp.Msg.BundleHash != "hash-1" { + if fc.applyMessage != "config(repo): validate golden config" { + t.Fatalf("applyMessage = %q", fc.applyMessage) + } + if !resp.Msg.Applied || resp.Msg.CorrelationId != "corr-1" || resp.Msg.BundleHash != "hash-1" || resp.Msg.Message != "config(repo): validate golden config" { t.Errorf("unexpected: %+v", resp.Msg) } } diff --git a/internal/cli/config.go b/internal/cli/config.go index 545fdb20..c63af027 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -148,7 +148,7 @@ func newConfigApplyCmd(gf *globalFlags) *cobra.Command { }, } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print diff without applying") - cmd.Flags().StringVar(&message, "message", "", "change message recorded with apply") + cmd.Flags().StringVar(&message, "message", "", `semantic change message, for example "config(scene): add evening scene"`) return cmd } diff --git a/internal/config/manager.go b/internal/config/manager.go index 6ad46a90..2599d600 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -139,8 +139,12 @@ func (m *Manager) Validate(ctx context.Context) (*configpb.ConfigSnapshot, *Conf } // Apply runs Validate, resolves secrets, applies carport side-effects, and appends ConfigApplied. -// If dryRun is true, stops after diff — no secrets resolved, no events appended. -func (m *Manager) Apply(ctx context.Context, dryRun bool) error { +// If dryRun is true, stops after diff with no secrets resolved and no events appended. +func (m *Manager) Apply(ctx context.Context, dryRun bool, message string) error { + message, err := NormalizeApplyMessage(message) + if err != nil { + return err + } snap, diff, err := m.Validate(ctx) if err != nil { return err @@ -195,6 +199,7 @@ func (m *Manager) Apply(ctx context.Context, dryRun bool) error { DriverInstancesRemoved: int32(len(diff.DriverInstancesRemoved)), DriverInstancesChanged: int32(len(diff.DriverInstancesChanged)), AutomationsChanged: int32(diff.AutomationsChanged), + Message: message, }, }}, }) diff --git a/internal/config/manager_apply_driver_test.go b/internal/config/manager_apply_driver_test.go index d4f60923..a57c76a4 100644 --- a/internal/config/manager_apply_driver_test.go +++ b/internal/config/manager_apply_driver_test.go @@ -97,7 +97,7 @@ driverInstances = new { if err != nil { t.Fatal(err) } - if err := mgr.Apply(ctx, false); err != nil { + if err := mgr.Apply(ctx, false, DefaultApplyMessage); err != nil { t.Fatal(err) } if len(cp.registered) != 1 { @@ -145,7 +145,7 @@ driverInstances = new { if err != nil { t.Fatal(err) } - if err := mgr.Apply(ctx, false); err != nil { + if err := mgr.Apply(ctx, false, DefaultApplyMessage); err != nil { t.Fatal(err) } if cp.registered[0].lifecycle.RestartBudgetMax != 99 { @@ -175,7 +175,7 @@ driverInstances = new { cp := &recordingCarport{} mgr, _ := NewManager(ctx, configDir, driversRoot, nopStore{}, cp) - if err := mgr.Apply(ctx, false); err != nil { + if err := mgr.Apply(ctx, false, DefaultApplyMessage); err != nil { t.Fatal(err) } if len(cp.registered) != 0 { @@ -204,7 +204,7 @@ driverInstances = new { cp := &recordingCarport{} mgr, _ := NewManager(ctx, configDir, driversRoot, nopStore{}, cp) - if err := mgr.Apply(ctx, false); err == nil { + if err := mgr.Apply(ctx, false, DefaultApplyMessage); err == nil { t.Fatal("expected error, got nil") } } diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index 5e1705f3..6e84fa26 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -66,7 +66,7 @@ func TestManager_Apply_CallsCarportAndAppends(t *testing.T) { registry: testRegistryWith("hue"), } - if err := mgr.Apply(context.Background(), false); err != nil { + if err := mgr.Apply(context.Background(), false, "config(driver): add hue driver"); err != nil { t.Fatalf("Apply: %v", err) } @@ -84,6 +84,9 @@ func TestManager_Apply_CallsCarportAndAppends(t *testing.T) { if applied.DriverInstancesAdded != 1 { t.Errorf("expected 1 driver added, got %d", applied.DriverInstancesAdded) } + if applied.Message != "config(driver): add hue driver" { + t.Errorf("message = %q", applied.Message) + } } func TestManager_Apply_DryRun_NoSideEffects(t *testing.T) { @@ -96,7 +99,7 @@ func TestManager_Apply_DryRun_NoSideEffects(t *testing.T) { fs := &fakeStore{} mgr := &Manager{configDir: "/fake", ev: &fakeEval{snap: snap}, store: fs, carportMgr: fc, registry: testRegistryWith("hue")} - if err := mgr.Apply(context.Background(), true); err != nil { + if err := mgr.Apply(context.Background(), true, DefaultApplyMessage); err != nil { t.Fatalf("dry-run Apply: %v", err) } if len(fc.registered) != 0 { @@ -129,7 +132,7 @@ func TestManager_Apply_StoresResolvedAndRedactedSnapshots(t *testing.T) { registry: testRegistryWith("hue"), } - if err := mgr.Apply(context.Background(), false); err != nil { + if err := mgr.Apply(context.Background(), false, DefaultApplyMessage); err != nil { t.Fatalf("Apply: %v", err) } if got := string(mgr.Current().DriverInstances[0].Params); !strings.Contains(got, "secret-value") { diff --git a/internal/config/semantic_message.go b/internal/config/semantic_message.go new file mode 100644 index 00000000..332ea0e0 --- /dev/null +++ b/internal/config/semantic_message.go @@ -0,0 +1,73 @@ +package config + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +const DefaultApplyMessage = "config(repo): apply configuration" + +var ( + ErrInvalidSemanticMessage = errors.New("invalid semantic config message") + semanticMessagePattern = regexp.MustCompile(`^config\(([a-z][a-z0-9-]*)\): ([a-z0-9][a-z0-9 ,` + "`" + `._:/()+-]*[^.])$`) +) + +var allowedConfigMessageScopes = map[string]struct{}{ + "area": {}, + "automation": {}, + "driver": {}, + "entity": {}, + "page": {}, + "policy": {}, + "scene": {}, + "script": {}, + "auth": {}, + "mcp": {}, + "repo": {}, +} + +type SemanticMessageError struct { + Message string + Reason string +} + +func (e *SemanticMessageError) Error() string { + if e.Message == "" { + return fmt.Sprintf("%v: %s", ErrInvalidSemanticMessage, e.Reason) + } + return fmt.Sprintf("%v %q: %s", ErrInvalidSemanticMessage, e.Message, e.Reason) +} + +func (e *SemanticMessageError) Unwrap() error { + return ErrInvalidSemanticMessage +} + +func NormalizeApplyMessage(message string) (string, error) { + message = strings.TrimSpace(message) + if message == "" { + message = DefaultApplyMessage + } + if strings.ContainsAny(message, "\r\n") { + return "", &SemanticMessageError{Message: message, Reason: "must be one line"} + } + matches := semanticMessagePattern.FindStringSubmatch(message) + if matches == nil { + return "", &SemanticMessageError{ + Message: message, + Reason: `expected "config(): "`, + } + } + scope := matches[1] + if _, ok := allowedConfigMessageScopes[scope]; !ok { + return "", &SemanticMessageError{ + Message: message, + Reason: fmt.Sprintf("unknown scope %q", scope), + } + } + if len(message) > 120 { + return "", &SemanticMessageError{Message: message, Reason: "must be 120 characters or fewer"} + } + return message, nil +} diff --git a/internal/config/semantic_message_test.go b/internal/config/semantic_message_test.go new file mode 100644 index 00000000..479d3619 --- /dev/null +++ b/internal/config/semantic_message_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "errors" + "testing" +) + +func TestNormalizeApplyMessage_Default(t *testing.T) { + got, err := NormalizeApplyMessage("") + if err != nil { + t.Fatalf("NormalizeApplyMessage: %v", err) + } + if got != DefaultApplyMessage { + t.Fatalf("message = %q, want %q", got, DefaultApplyMessage) + } +} + +func TestNormalizeApplyMessage_Valid(t *testing.T) { + got, err := NormalizeApplyMessage(" config(scene): add evening kitchen scene ") + if err != nil { + t.Fatalf("NormalizeApplyMessage: %v", err) + } + if got != "config(scene): add evening kitchen scene" { + t.Fatalf("message = %q", got) + } +} + +func TestNormalizeApplyMessage_Invalid(t *testing.T) { + tests := []string{ + "cli golden", + "feat(config): add config", + "config(bogus): add config", + "config(scene): Add scene", + "config(scene): add scene.", + "config(scene): add scene\nmore", + } + for _, tc := range tests { + t.Run(tc, func(t *testing.T) { + _, err := NormalizeApplyMessage(tc) + if !errors.Is(err, ErrInvalidSemanticMessage) { + t.Fatalf("err = %v, want ErrInvalidSemanticMessage", err) + } + }) + } +} diff --git a/internal/daemon/api_adapters.go b/internal/daemon/api_adapters.go index 2f263f31..7aa24f20 100644 --- a/internal/daemon/api_adapters.go +++ b/internal/daemon/api_adapters.go @@ -761,7 +761,7 @@ func (m *managerReloaderApplier) Apply(ctx context.Context, source string) error // `source` is just telemetry for the debounce log line; Manager // doesn't care about the reason. _ = source - return m.mgr.Apply(ctx, false) + return m.mgr.Apply(ctx, false, "config(repo): reload configuration") } func (a *configApplierAdapter) Validate(ctx context.Context, _ []byte) (bool, []string, api.ConfigDiff, string, error) { @@ -781,14 +781,18 @@ func (a *configApplierAdapter) Validate(ctx context.Context, _ []byte) (bool, [] return true, nil, d, "", nil } -func (a *configApplierAdapter) Apply(ctx context.Context, _ []byte, _, _ string, dryRun, _ bool, _ string) (api.ConfigApplyResult, error) { +func (a *configApplierAdapter) Apply(ctx context.Context, _ []byte, message, _ string, dryRun, _ bool, _ string) (api.ConfigApplyResult, error) { if a.mgr == nil { return api.ConfigApplyResult{}, fmt.Errorf("config manager not available") } - if err := a.mgr.Apply(ctx, dryRun); err != nil { - return api.ConfigApplyResult{Errors: []string{err.Error()}}, nil + normalized, err := config.NormalizeApplyMessage(message) + if err != nil { + return api.ConfigApplyResult{}, fmt.Errorf("%w: %v", api.ErrValidationFailed, err) + } + if err := a.mgr.Apply(ctx, dryRun, normalized); err != nil { + return api.ConfigApplyResult{}, err } - return api.ConfigApplyResult{Applied: !dryRun}, nil + return api.ConfigApplyResult{Applied: !dryRun, Message: normalized}, nil } func (a *configApplierAdapter) Reload(_ context.Context, _ string) (api.ConfigDiff, string, error) { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 528d0ebb..f9f1f1ad 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -295,7 +295,7 @@ func (d *Daemon) Run(ctx context.Context) (err error) { return fmt.Errorf("config manager: %w", err) } d.configMgr = cfgMgr - if err := d.configMgr.Apply(ctx, false); err != nil { + if err := d.configMgr.Apply(ctx, false, "config(repo): load startup configuration"); err != nil { d.logger.Error("initial config load failed", "err", err) return fmt.Errorf("config load: %w", err) } diff --git a/internal/mcp/tools/config.go b/internal/mcp/tools/config.go index c1680e4e..f94a71b6 100644 --- a/internal/mcp/tools/config.go +++ b/internal/mcp/tools/config.go @@ -20,6 +20,7 @@ type ValidateConfigInput struct { // ApplyConfigInput is the input schema for switchyard__apply_config. // PklBundle is a base64-encoded PKL bundle or a plain string containing PKL source. +// Message is optional; when present it must look like "config(scene): add scene". type ApplyConfigInput struct { PklBundle string `json:"pkl_bundle"` Message string `json:"message,omitempty"` @@ -59,7 +60,7 @@ func registerConfig(d Deps) { sdk.AddTool(d.Server, &sdk.Tool{ Name: "switchyard__apply_config", - Description: "Apply a PKL config bundle to the daemon.", + Description: "Apply a PKL config bundle to the daemon with an optional semantic config message.", }, func(ctx context.Context, _ *sdk.CallToolRequest, in ApplyConfigInput) (*sdk.CallToolResult, any, error) { req := connect.NewRequest(&v1.ApplyConfigRequest{ PklBundle: []byte(in.PklBundle), @@ -82,6 +83,7 @@ func registerConfig(d Deps) { } out := map[string]any{ "applied": resp.Msg.GetApplied(), + "message": resp.Msg.GetMessage(), "diff": diffRaw, } b, _ := json.Marshal(out) diff --git a/internal/pkllsp/client.go b/internal/pkllsp/client.go index dd96bc0e..2d5b1364 100644 --- a/internal/pkllsp/client.go +++ b/internal/pkllsp/client.go @@ -90,7 +90,7 @@ func startClient(ctx context.Context, cfg Config) (*client, error) { return nil, err } - procCtx, cancel := context.WithCancel(context.Background()) + procCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) cmd := exec.CommandContext(procCtx, bin) cmd.Env = os.Environ() if cfg.SwitchyardNamespaceDir != "" { @@ -127,12 +127,16 @@ func startClient(ctx context.Context, cfg Config) (*client, error) { opened: map[string]int32{}, diags: map[string]diagnosticState{}, } - go c.readLoop(stdout) + go c.readLoop(procCtx, stdout) go c.stderrLoop(stderr) go c.waitLoop() if err := c.initialize(ctx, cfg); err != nil { - c.close(context.Background()) + closeCtx, closeCancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + defer closeCancel() + if closeErr := c.close(closeCtx); closeErr != nil && cfg.Logger != nil { + cfg.Logger.Debug("pkl-lsp close after initialize failure", "err", closeErr) + } return nil, err } return c, nil @@ -219,12 +223,16 @@ func (c *client) stderrLoop(r io.Reader) { } } -func (c *client) readLoop(r io.Reader) { +func (c *client) readLoop(ctx context.Context, r io.Reader) { br := bufio.NewReader(r) for { body, err := readFramedMessage(br) if err != nil { - _ = c.close(context.Background()) + closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) + if closeErr := c.close(closeCtx); closeErr != nil && c.logger != nil { + c.logger.Debug("pkl-lsp close after read failure", "err", closeErr) + } + cancel() return } c.handleMessage(body) diff --git a/proto/switchyard/event/v1/event.proto b/proto/switchyard/event/v1/event.proto index 47180404..5760677e 100644 --- a/proto/switchyard/event/v1/event.proto +++ b/proto/switchyard/event/v1/event.proto @@ -100,6 +100,7 @@ message ConfigApplied { int32 driver_instances_changed = 4; int32 automations_changed = 5; bool dry_run = 6; + string message = 7; } // Top-level enum so both AutomationFinished and ScriptFinished can reference it. diff --git a/proto/switchyard/v1alpha1/config.proto b/proto/switchyard/v1alpha1/config.proto index d1e9d040..d9db8c42 100644 --- a/proto/switchyard/v1alpha1/config.proto +++ b/proto/switchyard/v1alpha1/config.proto @@ -37,6 +37,7 @@ message ApplyConfigResponse { ConfigDiff diff = 2; string correlation_id = 3; string bundle_hash = 4; + string message = 5; } message ReloadConfigRequest {} diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit deleted file mode 100755 index f5e9bb13..00000000 --- a/scripts/hooks/pre-commit +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# Pre-commit hook: fast static checks. -# Install via: scripts/install-hooks.sh -set -euo pipefail - -cd "$(git rev-parse --show-toplevel)" - -fail() { echo "pre-commit: $*" >&2; exit 1; } - -echo "pre-commit: tidy check..." -go mod tidy -git diff --exit-code go.mod go.sum || fail "go.mod/go.sum changed after 'go mod tidy' — stage the updated files" - -echo "pre-commit: proto regeneration..." -if git diff --cached --name-only --diff-filter=d | grep -q '\.proto$'; then - if command -v buf >/dev/null 2>&1; then - PATH="$PATH:$(go env GOPATH)/bin" buf generate - git add gen/ - else - echo "pre-commit: buf not found, skipping proto regeneration (CI will verify)" - fi -fi - -echo "pre-commit: build (native)..." -go build ./... || fail "build failed" - -echo "pre-commit: build (darwin/arm64)..." -GOOS=darwin GOARCH=arm64 go build ./... || fail "cross-compile to darwin/arm64 failed — likely platform-specific code without a build constraint" - -echo "pre-commit: lint..." -if command -v golangci-lint >/dev/null 2>&1; then - golangci-lint run ./... -else - echo "pre-commit: golangci-lint not found, skipping lint" -fi - -echo "pre-commit: pkl schema check..." -if command -v pkl >/dev/null 2>&1; then - # Evaluate the testdata/valid config to exercise the full schema pipeline. - # Requires the integration build tag so the evaluator test helper is compiled. - go test -tags=integration -run TestEvaluator_ValidConfig ./internal/config/ || fail "pkl schema evaluation failed" -else - echo "pre-commit: pkl not found, skipping schema check" -fi - -echo "pre-commit: ok" diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push deleted file mode 100755 index 8467daee..00000000 --- a/scripts/hooks/pre-push +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Pre-push hook: full test suite including race detector. -# Install via: scripts/install-hooks.sh -set -euo pipefail - -cd "$(git rev-parse --show-toplevel)" - -fail() { echo "pre-push: $*" >&2; exit 1; } - -echo "pre-push: unit tests..." -go test ./... || fail "unit tests failed" - -echo "pre-push: race detector..." -go test -race ./... || fail "race tests failed" - -echo "pre-push: integration tests..." -if command -v pkl >/dev/null 2>&1; then - go test -tags=integration ./... || fail "integration tests failed" -else - echo "pre-push: pkl not found — skipping integration tests (CI will run them)" - go test -tags=integration $(go list -tags=integration ./... | grep -v 'internal/config') || fail "integration tests failed" -fi - -echo "pre-push: ok" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index 1d93551d..1e9a34ff 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -1,19 +1,6 @@ -#!/usr/bin/env bash -# Installs git hooks from scripts/hooks/ into .git/hooks/. -# Run once after cloning: scripts/install-hooks.sh -set -euo pipefail +#!/usr/bin/env sh +set -eu -REPO_ROOT="$(git rev-parse --show-toplevel)" -GIT_DIR="$(git rev-parse --git-dir)" -HOOKS_SRC="$REPO_ROOT/scripts/hooks" -HOOKS_DST="$GIT_DIR/hooks" - -for hook in "$HOOKS_SRC"/*; do - name="$(basename "$hook")" - dst="$HOOKS_DST/$name" - ln -sf "$hook" "$dst" - chmod +x "$hook" - echo "installed $name" -done - -echo "hooks installed — run 'git hook list' to verify" +cd "$(git rev-parse --show-toplevel)" +git config core.hooksPath .githooks +echo "hooks path set to .githooks" diff --git a/testdata/cli/config-apply-dry-run.golden.txt b/testdata/cli/config-apply-dry-run.golden.txt index ddc98272..8278b130 100644 --- a/testdata/cli/config-apply-dry-run.golden.txt +++ b/testdata/cli/config-apply-dry-run.golden.txt @@ -1,4 +1,4 @@ -$ switchyard --data-dir --endpoint unix:///switchyardd.sock --no-color --format json config apply --dry-run --message cli golden +$ switchyard --data-dir --endpoint unix:///switchyardd.sock --no-color --format json config apply --dry-run --message config(repo): validate golden config exit: 0 stdout: Config applied (dry-run) diff --git a/testdata/cli/events-export.golden.txt b/testdata/cli/events-export.golden.txt index 31df57be..86d7b71a 100644 --- a/testdata/cli/events-export.golden.txt +++ b/testdata/cli/events-export.golden.txt @@ -1,5 +1,5 @@ $ switchyard --data-dir --endpoint unix:///switchyardd.sock --no-color --format json events export --to 1 exit: 0 stdout: -{"correlation_id":"","entity":"","kind":"config","payload":{"configApplied":{"appliedAtUnixMs":"","automationsChanged":1,"driverInstancesAdded":1}},"position":,"source":"config.Manager","timestamp":""} +{"correlation_id":"","entity":"","kind":"config","payload":{"configApplied":{"appliedAtUnixMs":"","automationsChanged":1,"driverInstancesAdded":1,"message":"config(repo): load startup configuration"}},"position":,"source":"config.Manager","timestamp":""} stderr: diff --git a/testdata/cli/events-inspect.golden.txt b/testdata/cli/events-inspect.golden.txt index 224998c1..2d674138 100644 --- a/testdata/cli/events-inspect.golden.txt +++ b/testdata/cli/events-inspect.golden.txt @@ -13,7 +13,8 @@ Payload "configApplied": { "appliedAtUnixMs":"", "driverInstancesAdded": 1, - "automationsChanged": 1 + "automationsChanged": 1, + "message": "config(repo): load startup configuration" } } stderr: