From 6244b9c2de72754cdc53440fa05ab9ce2380f446 Mon Sep 17 00:00:00 2001 From: Fynn Date: Wed, 13 May 2026 23:59:39 -0700 Subject: [PATCH 1/3] fix(ci): build Vue app in web pipeline --- .github/workflows/ci.yml | 124 +++++++++------------------------ .gitignore | 6 +- Taskfile.yml | 45 +++++------- app/package.json | 2 +- app/scripts/clean-web-dist.mjs | 14 ++++ app/vite.config.ts | 13 ++++ internal/web/budget_test.go | 5 +- internal/web/dist/.gitkeep | 0 internal/web/handler.go | 3 +- internal/web/handler_test.go | 6 +- internal/web/index_template.go | 5 +- 11 files changed, 95 insertions(+), 128 deletions(-) create mode 100644 app/scripts/clean-web-dist.mjs create mode 100644 internal/web/dist/.gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb55d166..61452cd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: fuzz_eventstore: ${{ steps.filter.outputs.fuzz_eventstore }} fuzz_registry: ${{ steps.filter.outputs.fuzz_registry }} fuzz_carport: ${{ steps.filter.outputs.fuzz_carport }} - web: ${{ steps.filter.outputs.web }} + app: ${{ steps.filter.outputs.app }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -30,7 +30,7 @@ jobs: - 'internal/**' - 'proto/**' - 'scripts/**' - - 'web/**' + - 'app/**' - 'testdata/**' - 'examples/**' - 'go.mod' @@ -68,9 +68,9 @@ jobs: - 'go.work' - 'go.work.sum' - 'Taskfile.yml' - web: + app: - '.github/workflows/ci.yml' - - 'web/**' + - 'app/**' - 'cmd/switchyardd/**' - 'internal/activity/**' - 'internal/api/**' @@ -83,9 +83,9 @@ jobs: - 'go.work' - 'go.work.sum' - web-lint: + app-typecheck: needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.web == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.app == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -93,15 +93,15 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: web/package-lock.json - - name: Install web dependencies - run: npm ci --prefix web - - name: Lint web - run: npm run lint --prefix web + cache-dependency-path: app/package-lock.json + - name: Install app dependencies + run: npm ci --prefix app + - name: Type-check app + run: npm run typecheck --prefix app - web-test: + app-test: needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.web == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.app == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -109,15 +109,15 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: web/package-lock.json - - name: Install web dependencies - run: npm ci --prefix web - - name: Test web - run: npm run test --prefix web + cache-dependency-path: app/package-lock.json + - name: Install app dependencies + run: npm ci --prefix app + - name: Test app + run: npm test --prefix app - web-build: + app-build: needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.web == 'true' + if: github.event_name != 'pull_request' || needs.changes.outputs.app == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -125,67 +125,11 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: web/package-lock.json - - name: Install web dependencies - run: npm ci --prefix web - - name: Build web bundle - run: npm run build --prefix web - - web-e2e: - needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.web == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: '1.25' - cache: true - cache-dependency-path: go.sum - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: web/package-lock.json - - name: Install Pkl - run: | - sudo curl -fsSL -o /usr/local/bin/pkl "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-amd64" - sudo chmod +x /usr/local/bin/pkl - pkl --version - - name: Install web dependencies - run: npm ci --prefix web - - name: Install Playwright browsers - run: npx --prefix web playwright install --with-deps chromium - - name: Run Playwright e2e tests - run: npm run test:e2e --prefix web - - web-playwright: - needs: changes - if: github.event_name != 'pull_request' || needs.changes.outputs.web == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: '1.25' - cache: true - cache-dependency-path: go.sum - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: web/package-lock.json - - name: Install Pkl - run: | - sudo curl -fsSL -o /usr/local/bin/pkl "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-amd64" - sudo chmod +x /usr/local/bin/pkl - pkl --version - - name: Install web dependencies - run: npm ci --prefix web - - name: Install Playwright browsers - run: npx --prefix web playwright install --with-deps chromium - - name: Run Playwright - run: npm run test:e2e --prefix web + cache-dependency-path: app/package-lock.json + - name: Install app dependencies + run: npm ci --prefix app + - name: Build app bundle + run: npm run build --prefix app # ── switchyard ────────────────────────────────────────────────────────────────── @@ -203,6 +147,11 @@ jobs: go-version: '1.25' cache: true cache-dependency-path: go.sum + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: app/package-lock.json - name: Install Task uses: arduino/setup-task@v2 @@ -229,13 +178,6 @@ jobs: sudo chmod +x /usr/local/bin/pkl pkl --version - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: web/package-lock.json - - name: Regenerate proto (verify committed in sync) run: | PATH="$PATH:$(go env GOPATH)/bin" buf generate @@ -269,9 +211,9 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: web/package-lock.json - - name: Build web frontend (required for embed.go) - run: npm ci --prefix web && npm run build --prefix web + cache-dependency-path: app/package-lock.json + - name: Build app bundle (required for embed.go) + run: npm ci --prefix app && npm run build --prefix app - uses: golangci/golangci-lint-action@v7 with: version: v2.11.4 diff --git a/.gitignore b/.gitignore index 5a2b90b2..15bce236 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,10 @@ var/ /gohomed.lock /gohomed.sock -# Embedded web assets -internal/web/dist/ +# Embedded web asset build outputs; keep the placeholder committed. +!internal/web/dist/ +internal/web/dist/* +!internal/web/dist/.gitkeep # Docs build output docs/site/ diff --git a/Taskfile.yml b/Taskfile.yml index 683946fd..e3b420a6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -17,50 +17,43 @@ tasks: cmds: - PATH="$(go env GOPATH)/bin:$PATH" buf generate --path proto/switchyard/activity/v1/ - web:install: - desc: Install web npm dependencies (idempotent) - dir: web + app:install: + desc: Install app npm dependencies (idempotent) + dir: app cmds: - npm install --no-audit --no-fund - web:build: - desc: Build the web bundle into internal/web/dist - dir: web - deps: [web:install] + app:build: + desc: Build the Vue app bundle into internal/web/dist + dir: app + deps: [app:install] cmds: - npm run build - web:lint: - desc: Lint web sources - dir: web - deps: [web:install] + app:typecheck: + desc: Type-check app sources + dir: app + deps: [app:install] cmds: - - npm run lint + - npm run typecheck - web:test: - desc: Run web unit tests - dir: web - deps: [web:install] + app:test: + desc: Run app unit tests + dir: app + deps: [app:install] cmds: - npm test - web:e2e: - desc: Run Playwright end-to-end tests (requires a running switchyardd) - dir: web - deps: [web:install] - cmds: - - npm run test:e2e - ui:dev: desc: Start Vite dev server (proxies to switchyardd) - dir: web - deps: [web:install] + dir: app + deps: [app:install] cmds: - npm run dev build: desc: Build both binaries - deps: [web:build] + deps: [app:build] cmds: - go build -o {{.BIN_DIR}}/switchyardd ./cmd/switchyardd - go build -o {{.BIN_DIR}}/switchyard ./cmd/switchyard diff --git a/app/package.json b/app/package.json index c64daa42..443430f0 100644 --- a/app/package.json +++ b/app/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc -b && vite build", + "build": "node scripts/clean-web-dist.mjs && vue-tsc -b && vite build", "preview": "vite preview", "test": "vitest run", "typecheck": "vue-tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", diff --git a/app/scripts/clean-web-dist.mjs b/app/scripts/clean-web-dist.mjs new file mode 100644 index 00000000..7b25dca9 --- /dev/null +++ b/app/scripts/clean-web-dist.mjs @@ -0,0 +1,14 @@ +import { rm, readdir, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const appRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const dist = path.resolve(appRoot, "../internal/web/dist"); + +await mkdir(dist, { recursive: true }); +for (const entry of await readdir(dist)) { + if (entry === ".gitkeep") { + continue; + } + await rm(path.join(dist, entry), { force: true, recursive: true }); +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 98b09376..80137289 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -59,6 +59,19 @@ export default defineConfig({ "@": path.resolve(__dirname, "src"), }, }, + build: { + outDir: "../internal/web/dist", + emptyOutDir: false, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes("node_modules/monaco-editor")) { + return "monaco"; + } + }, + }, + }, + }, server: { port: 5174, strictPort: true, diff --git a/internal/web/budget_test.go b/internal/web/budget_test.go index 653a4f94..57b9a3bd 100644 --- a/internal/web/budget_test.go +++ b/internal/web/budget_test.go @@ -20,7 +20,10 @@ const ( func TestAssetBudget(t *testing.T) { dist, err := fs.Sub(web.Assets, "dist/assets") if err != nil { - t.Skipf("dist/assets not found (run web:build first): %v", err) + t.Skipf("dist/assets not found: %v", err) + } + if _, err := fs.Stat(dist, "."); err != nil { + t.Skipf("dist/assets not found: %v", err) } var total int64 var initial int64 diff --git a/internal/web/dist/.gitkeep b/internal/web/dist/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/web/handler.go b/internal/web/handler.go index d86c51c5..746acf94 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -20,8 +20,7 @@ type Handler struct { assetFS http.FileSystem } -// NewHandler constructs a Handler. The dist/ directory must be populated -// (by task web:build) before calling this at startup. +// NewHandler constructs a Handler from the embedded app bundle. func NewHandler(cfg Config) (*Handler, error) { dist, err := fs.Sub(Assets, "dist") if err != nil { diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index 2ea6851b..c9252dda 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -24,8 +24,8 @@ func TestHandler_ServesIndexAtRoot(t *testing.T) { if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("Content-Type = %q, want text/html prefix", ct) } - if !strings.Contains(rec.Body.String(), `id="root"`) { - t.Errorf("body missing root div: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), `id="app"`) { + t.Errorf("body missing app div: %s", rec.Body.String()) } } @@ -37,7 +37,7 @@ func TestHandler_FallsBackToIndexForUnknownRoute(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (SPA fallback)", rec.Code) } - if !strings.Contains(rec.Body.String(), `id="root"`) { + if !strings.Contains(rec.Body.String(), `id="app"`) { t.Error("expected SPA index for unknown route") } } diff --git a/internal/web/index_template.go b/internal/web/index_template.go index 7a9c83a0..d08eb233 100644 --- a/internal/web/index_template.go +++ b/internal/web/index_template.go @@ -2,6 +2,7 @@ package web import ( "bytes" + "errors" "fmt" "html/template" "io/fs" @@ -21,7 +22,7 @@ const indexTemplateSource = ` {{.AssetTags}} -
+
{{.ScriptTags}} @@ -29,7 +30,7 @@ const indexTemplateSource = ` func renderIndex(version string, dist fs.FS) ([]byte, error) { assetTags, scriptTags, err := scanDist(dist) - if err != nil { + if err != nil && !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("web: scan dist: %w", err) } tmpl, err := template.New("index").Parse(indexTemplateSource) From 2876c3d67cc28e3ca970490ebcbb32d630c0928e Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 14 May 2026 00:15:09 -0700 Subject: [PATCH 2/3] Fix CI lint and registry coverage --- .github/workflows/ci.yml | 2 +- internal/activity/service.go | 7 -- internal/activity/stories/story.go | 12 ++-- internal/api/listener/routes.go | 40 +++++------ internal/api/service_unimplemented.go | 9 --- internal/api/service_unimplemented_test.go | 2 - .../automation/regen/entity_areas_test.go | 2 +- internal/commandcatalog/registry_test.go | 6 +- internal/commandcatalog/service_test.go | 2 +- internal/config/discovery.go | 8 ++- internal/config/evaluator.go | 4 +- internal/config/evaluator_decode.go | 1 - internal/config/reloader.go | 6 +- internal/daemon/daemon.go | 34 ++++----- internal/display/fidelity_recommender.go | 6 +- internal/display/fidelity_recommender_test.go | 42 +++++------ internal/display/service.go | 18 ++--- internal/driver/management/service_test.go | 20 +++--- internal/editsession/regenerability.go | 6 +- internal/editsession/watcher.go | 2 +- internal/interestingness/configuration.go | 8 +-- internal/interestingness/novelty.go | 6 +- internal/registry/areas_test.go | 69 +++++++++++++++++++ internal/replay/service.go | 6 +- internal/replay/service_test.go | 40 +++++------ internal/replay/snapshots.go | 18 ++--- internal/starlarkls/diagnose.go | 39 ++++++----- internal/starlarkls/service.go | 1 + internal/starlarkls/service_test.go | 1 + internal/starlarkls/symbols.go | 4 +- internal/widgetpack/service.go | 2 +- 31 files changed, 241 insertions(+), 182 deletions(-) delete mode 100644 internal/api/service_unimplemented.go delete mode 100644 internal/api/service_unimplemented_test.go create mode 100644 internal/registry/areas_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61452cd7..4d66a72f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,7 +165,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Install protoc-gen-go-grpc - run: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1 + run: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 - name: Install Pkl run: | diff --git a/internal/activity/service.go b/internal/activity/service.go index ad8c015c..3287ed8a 100644 --- a/internal/activity/service.go +++ b/internal/activity/service.go @@ -475,13 +475,6 @@ func filterWindow(since, until *timestamppb.Timestamp) (time.Time, time.Time) { return s, u } -func kindFilter(kind string) []string { - if kind == "" { - return nil - } - return []string{kind} -} - func matchesStoriesFilter(story *activityv1.Story, filter *activityv1.StoriesFilter) bool { if filter == nil { return true diff --git a/internal/activity/stories/story.go b/internal/activity/stories/story.go index df1d6017..dca545f5 100644 --- a/internal/activity/stories/story.go +++ b/internal/activity/stories/story.go @@ -36,12 +36,12 @@ func (s Story) ToProto() *activityv1.Story { } story := &activityv1.Story{ - Id: s.ID, - Title: s.Title, - InnerEventIds: s.InnerEventIDs, - Tags: tags, - Source: s.Source, - EntityIds: s.EntityIDs, + Id: s.ID, + Title: s.Title, + InnerEventIds: s.InnerEventIDs, + Tags: tags, + Source: s.Source, + EntityIds: s.EntityIDs, } if !s.OccurredAt.IsZero() { story.OccurredAt = timestamppb.New(s.OccurredAt) diff --git a/internal/api/listener/routes.go b/internal/api/listener/routes.go index b2fb7cb4..335b73e5 100644 --- a/internal/api/listener/routes.go +++ b/internal/api/listener/routes.go @@ -17,28 +17,28 @@ import ( // Services is the set of handler implementations the listener needs. type Services struct { - System switchyardv1alpha1connect.SystemServiceHandler - Area switchyardv1alpha1connect.AreaServiceHandler - Zone switchyardv1alpha1connect.ZoneServiceHandler - Device switchyardv1alpha1connect.DeviceServiceHandler - Entity switchyardv1alpha1connect.EntityServiceHandler - Driver switchyardv1alpha1connect.DriverServiceHandler - Event switchyardv1alpha1connect.EventServiceHandler - Config switchyardv1alpha1connect.ConfigServiceHandler - Automation switchyardv1alpha1connect.AutomationServiceHandler - Script switchyardv1alpha1connect.ScriptServiceHandler - Scene switchyardv1alpha1connect.SceneServiceHandler - Page pagev1connect.PageServiceHandler - Auth switchyardv1alpha1connect.AuthServiceHandler - WidgetPack switchyardv1alpha1connect.WidgetPackServiceHandler - CommandCatalog commandcatalogv1connect.CommandCatalogServiceHandler + System switchyardv1alpha1connect.SystemServiceHandler + Area switchyardv1alpha1connect.AreaServiceHandler + Zone switchyardv1alpha1connect.ZoneServiceHandler + Device switchyardv1alpha1connect.DeviceServiceHandler + Entity switchyardv1alpha1connect.EntityServiceHandler + Driver switchyardv1alpha1connect.DriverServiceHandler + Event switchyardv1alpha1connect.EventServiceHandler + Config switchyardv1alpha1connect.ConfigServiceHandler + Automation switchyardv1alpha1connect.AutomationServiceHandler + Script switchyardv1alpha1connect.ScriptServiceHandler + Scene switchyardv1alpha1connect.SceneServiceHandler + Page pagev1connect.PageServiceHandler + Auth switchyardv1alpha1connect.AuthServiceHandler + WidgetPack switchyardv1alpha1connect.WidgetPackServiceHandler + CommandCatalog commandcatalogv1connect.CommandCatalogServiceHandler DriverManagement driverv1connect.DriverManagementServiceHandler EditSession editsessionv1connect.EditSessionServiceHandler - StarlarkLs starlarklsv1connect.StarlarkLsServiceHandler - Activity activityv1connect.ActivityServiceHandler - Replay replayv1connect.ReplayServiceHandler - Display displayv1connect.DisplayServiceHandler - Solar solarv1connect.SolarServiceHandler + StarlarkLs starlarklsv1connect.StarlarkLsServiceHandler + Activity activityv1connect.ActivityServiceHandler + Replay replayv1connect.ReplayServiceHandler + Display displayv1connect.DisplayServiceHandler + Solar solarv1connect.SolarServiceHandler } // BuildRoutes returns the (path, handler) pairs to mount on the listener mux. diff --git a/internal/api/service_unimplemented.go b/internal/api/service_unimplemented.go deleted file mode 100644 index b220673a..00000000 --- a/internal/api/service_unimplemented.go +++ /dev/null @@ -1,9 +0,0 @@ -package api - -import ( - "context" -) - -func unimplemented(ctx context.Context, reason string) error { - return ToConnect(ctx, ErrNotImplemented, reason) -} diff --git a/internal/api/service_unimplemented_test.go b/internal/api/service_unimplemented_test.go deleted file mode 100644 index 26b311a1..00000000 --- a/internal/api/service_unimplemented_test.go +++ /dev/null @@ -1,2 +0,0 @@ -package api_test - diff --git a/internal/automation/regen/entity_areas_test.go b/internal/automation/regen/entity_areas_test.go index 31b896b3..2961fa77 100644 --- a/internal/automation/regen/entity_areas_test.go +++ b/internal/automation/regen/entity_areas_test.go @@ -21,7 +21,7 @@ func TestRenderEntityAreas_EmittedSorted(t *testing.T) { idxA := strings.Index(s, `["light.a"]`) idxB := strings.Index(s, `["light.b"]`) idxC := strings.Index(s, `["light.c"]`) - if !(idxA >= 0 && idxA < idxB && idxB < idxC) { + if idxA < 0 || idxA >= idxB || idxB >= idxC { t.Fatalf("entries not sorted by key:\n%s", s) } for _, want := range []string{ diff --git a/internal/commandcatalog/registry_test.go b/internal/commandcatalog/registry_test.go index c0eb40d2..c2865e22 100644 --- a/internal/commandcatalog/registry_test.go +++ b/internal/commandcatalog/registry_test.go @@ -12,10 +12,10 @@ import ( "github.com/fdatoo/switchyard/internal/automation" "github.com/fdatoo/switchyard/internal/commandcatalog" "github.com/fdatoo/switchyard/internal/config" - "github.com/fdatoo/switchyard/internal/page" "github.com/fdatoo/switchyard/internal/display" "github.com/fdatoo/switchyard/internal/driver" "github.com/fdatoo/switchyard/internal/entity" + "github.com/fdatoo/switchyard/internal/page" "github.com/fdatoo/switchyard/internal/pkl" "github.com/fdatoo/switchyard/internal/widgetpack" ) @@ -136,10 +136,6 @@ func TestAllDomainVerbs(t *testing.T) { byName[v.Name] = v } - type argExpect struct { - name string - required bool - } cases := []struct { verb string required []string diff --git a/internal/commandcatalog/service_test.go b/internal/commandcatalog/service_test.go index 5917f517..5af0aa66 100644 --- a/internal/commandcatalog/service_test.go +++ b/internal/commandcatalog/service_test.go @@ -17,10 +17,10 @@ import ( "github.com/fdatoo/switchyard/internal/automation" "github.com/fdatoo/switchyard/internal/commandcatalog" "github.com/fdatoo/switchyard/internal/config" - "github.com/fdatoo/switchyard/internal/page" "github.com/fdatoo/switchyard/internal/display" "github.com/fdatoo/switchyard/internal/driver" "github.com/fdatoo/switchyard/internal/entity" + "github.com/fdatoo/switchyard/internal/page" "github.com/fdatoo/switchyard/internal/pkl" "github.com/fdatoo/switchyard/internal/widgetpack" ) diff --git a/internal/config/discovery.go b/internal/config/discovery.go index 9c6d26fb..fe47fde6 100644 --- a/internal/config/discovery.go +++ b/internal/config/discovery.go @@ -4,12 +4,12 @@ import ( "context" "encoding/json" "errors" - "fmt" "io/fs" "os" "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" @@ -151,8 +151,10 @@ func pklErrorLine(msg string) int { if len(m) < 2 { return 0 } - var n int - fmt.Sscanf(m[1], "%d", &n) + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0 + } return n } diff --git a/internal/config/evaluator.go b/internal/config/evaluator.go index 1c674c7e..1ef2b7fd 100644 --- a/internal/config/evaluator.go +++ b/internal/config/evaluator.go @@ -133,7 +133,9 @@ func (e *pklEvaluator) Evaluate(ctx context.Context, configDir string) (*configp } disc, discErrs := discoverConfigDir(ctx, e, configDir) merged, mergeErrs, mergeErr := mergeDiscovered(snap, disc) - allErrs := append(discErrs, mergeErrs...) + allErrs := make([]ValidationError, 0, len(discErrs)+len(mergeErrs)) + allErrs = append(allErrs, discErrs...) + allErrs = append(allErrs, mergeErrs...) if mergeErr != nil { return merged, allErrs, mergeErr } diff --git a/internal/config/evaluator_decode.go b/internal/config/evaluator_decode.go index a7e11a91..e87f7111 100644 --- a/internal/config/evaluator_decode.go +++ b/internal/config/evaluator_decode.go @@ -460,4 +460,3 @@ func sceneFromJSON(s sceneJSON) (*configpb.SceneConfig, error) { } return scfg, nil } - diff --git a/internal/config/reloader.go b/internal/config/reloader.go index 8aed53bb..eb7e4fbb 100644 --- a/internal/config/reloader.go +++ b/internal/config/reloader.go @@ -23,9 +23,9 @@ type Reloader struct { app ReloaderApplier debounce time.Duration - mu sync.Mutex - pending []string - lastErr string + mu sync.Mutex + pending []string + lastErr string signal chan struct{} } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e20eb3ac..9478f6cd 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -27,7 +27,6 @@ import ( "github.com/fdatoo/switchyard/internal/activity" "github.com/fdatoo/switchyard/internal/api" "github.com/fdatoo/switchyard/internal/api/listener" - drvmgmt "github.com/fdatoo/switchyard/internal/driver/management" "github.com/fdatoo/switchyard/internal/auth" "github.com/fdatoo/switchyard/internal/auth/audit" "github.com/fdatoo/switchyard/internal/auth/authn" @@ -41,20 +40,21 @@ import ( "github.com/fdatoo/switchyard/internal/carport" "github.com/fdatoo/switchyard/internal/commandcatalog" "github.com/fdatoo/switchyard/internal/config" - "github.com/fdatoo/switchyard/internal/page" "github.com/fdatoo/switchyard/internal/display" "github.com/fdatoo/switchyard/internal/driver" - "github.com/fdatoo/switchyard/internal/solar" + drvmgmt "github.com/fdatoo/switchyard/internal/driver/management" "github.com/fdatoo/switchyard/internal/editsession" "github.com/fdatoo/switchyard/internal/entity" "github.com/fdatoo/switchyard/internal/eventstore" "github.com/fdatoo/switchyard/internal/mcp" "github.com/fdatoo/switchyard/internal/observability" + "github.com/fdatoo/switchyard/internal/page" "github.com/fdatoo/switchyard/internal/pkl" "github.com/fdatoo/switchyard/internal/policy" "github.com/fdatoo/switchyard/internal/registry" "github.com/fdatoo/switchyard/internal/replay" "github.com/fdatoo/switchyard/internal/script" + "github.com/fdatoo/switchyard/internal/solar" starlark "github.com/fdatoo/switchyard/internal/starlark" "github.com/fdatoo/switchyard/internal/starlarkls" "github.com/fdatoo/switchyard/internal/state" @@ -606,18 +606,18 @@ func (d *Daemon) Run(ctx context.Context) (err error) { replaySvc := replay.NewService(replayAdapter, replayAdapter, replayAdapter, replayAdapter) services := listener.Services{ - System: api.NewSystemService(sysBE), - Area: api.NewAreaService(areaRd), - Zone: api.NewZoneService(zoneRd), - Device: api.NewDeviceService(devRd, devWr), - Entity: entSvc, - Driver: api.NewDriverService(drvCtl), - Event: api.NewEventService(evtSrc), - Config: api.NewConfigService(cfgAppl), - Automation: api.NewAutomationService(autoCtl), - Script: api.NewScriptService(scriptRun, &eventAppenderAdapter{store: d.store}, sysBE), - Scene: api.NewRealSceneService(d.configMgr, &sceneInvokerAdapter{applier: d.sceneApplier}, d.logger), - Page: page.NewService(newPageBackend(configDir, driversDir, packStore), page.NewCatalog(nil)), + System: api.NewSystemService(sysBE), + Area: api.NewAreaService(areaRd), + Zone: api.NewZoneService(zoneRd), + Device: api.NewDeviceService(devRd, devWr), + Entity: entSvc, + Driver: api.NewDriverService(drvCtl), + Event: api.NewEventService(evtSrc), + Config: api.NewConfigService(cfgAppl), + Automation: api.NewAutomationService(autoCtl), + Script: api.NewScriptService(scriptRun, &eventAppenderAdapter{store: d.store}, sysBE), + Scene: api.NewRealSceneService(d.configMgr, &sceneInvokerAdapter{applier: d.sceneApplier}, d.logger), + Page: page.NewService(newPageBackend(configDir, driversDir, packStore), page.NewCatalog(nil)), WidgetPack: packService, CommandCatalog: cmdCatalogSvc, Auth: api.NewAuthService(api.AuthDeps{ @@ -638,8 +638,8 @@ func (d *Daemon) Run(ctx context.Context) (err error) { Activity: activitySvc, DriverManagement: drvMgmtSvc, Replay: replaySvc, - Display: display.NewService(filepath.Join(dataDir, "displays"), display.NewPairCodeStore()), - Solar: solar.NewService(), + Display: display.NewService(filepath.Join(dataDir, "displays"), display.NewPairCodeStore()), + Solar: solar.NewService(), } authnChain := auth.Chain(auth.LocalPeerCred{}, bearer, authn.NewSessionCookie(sessStore), auth.RejectAll{}) diff --git a/internal/display/fidelity_recommender.go b/internal/display/fidelity_recommender.go index 8bdd3103..9bc119d5 100644 --- a/internal/display/fidelity_recommender.go +++ b/internal/display/fidelity_recommender.go @@ -6,9 +6,9 @@ import ( // RoomStats holds scoring inputs for a single room. type RoomStats struct { - RoomID string - EntityCount int - SensorCount int + RoomID string + EntityCount int + SensorCount int InteractionCount30d int } diff --git a/internal/display/fidelity_recommender_test.go b/internal/display/fidelity_recommender_test.go index 83a94169..8a7dfdb0 100644 --- a/internal/display/fidelity_recommender_test.go +++ b/internal/display/fidelity_recommender_test.go @@ -13,64 +13,64 @@ func TestFidelityRecommender(t *testing.T) { r := NewFidelityRecommender() tests := []struct { - name string - room RoomStats - wantScenes int32 - wantMetric displayv1.TileMetric - wantWidth displayv1.TileWidth + name string + room RoomStats + wantScenes int32 + wantMetric displayv1.TileMetric + wantWidth displayv1.TileWidth }{ { - name: "rich: entity>=5 AND interaction>=10", - room: RoomStats{RoomID: "a", EntityCount: 6, SensorCount: 0, InteractionCount30d: 12}, + name: "rich: entity>=5 AND interaction>=10", + room: RoomStats{RoomID: "a", EntityCount: 6, SensorCount: 0, InteractionCount30d: 12}, wantScenes: 4, wantMetric: displayv1.TileMetric_TILE_METRIC_SENSOR, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, }, { - name: "balanced: entity>=3", - room: RoomStats{RoomID: "b", EntityCount: 3, SensorCount: 0, InteractionCount30d: 0}, + name: "balanced: entity>=3", + room: RoomStats{RoomID: "b", EntityCount: 3, SensorCount: 0, InteractionCount30d: 0}, wantScenes: 2, wantMetric: displayv1.TileMetric_TILE_METRIC_PRESENCE, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, }, { - name: "balanced: interaction>=5", - room: RoomStats{RoomID: "c", EntityCount: 1, SensorCount: 0, InteractionCount30d: 5}, + name: "balanced: interaction>=5", + room: RoomStats{RoomID: "c", EntityCount: 1, SensorCount: 0, InteractionCount30d: 5}, wantScenes: 2, wantMetric: displayv1.TileMetric_TILE_METRIC_PRESENCE, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, }, { - name: "minimal: below all thresholds", - room: RoomStats{RoomID: "d", EntityCount: 2, SensorCount: 0, InteractionCount30d: 4}, + name: "minimal: below all thresholds", + room: RoomStats{RoomID: "d", EntityCount: 2, SensorCount: 0, InteractionCount30d: 4}, wantScenes: 0, wantMetric: displayv1.TileMetric_TILE_METRIC_NONE, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, }, { - name: "sensor override: sensor_count>=1 forces metric=sensor", - room: RoomStats{RoomID: "e", EntityCount: 1, SensorCount: 1, InteractionCount30d: 0}, + name: "sensor override: sensor_count>=1 forces metric=sensor", + room: RoomStats{RoomID: "e", EntityCount: 1, SensorCount: 1, InteractionCount30d: 0}, wantScenes: 0, // minimal scenes (entity<3 and interaction<5) but sensor metric wantMetric: displayv1.TileMetric_TILE_METRIC_SENSOR, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, }, { - name: "sensor override on balanced tier", - room: RoomStats{RoomID: "f", EntityCount: 3, SensorCount: 2, InteractionCount30d: 0}, + name: "sensor override on balanced tier", + room: RoomStats{RoomID: "f", EntityCount: 3, SensorCount: 2, InteractionCount30d: 0}, wantScenes: 2, wantMetric: displayv1.TileMetric_TILE_METRIC_SENSOR, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, }, { - name: "boundary: entity=5 but interaction=9 (not rich)", - room: RoomStats{RoomID: "g", EntityCount: 5, SensorCount: 0, InteractionCount30d: 9}, + name: "boundary: entity=5 but interaction=9 (not rich)", + room: RoomStats{RoomID: "g", EntityCount: 5, SensorCount: 0, InteractionCount30d: 9}, wantScenes: 2, // entity>=3 → balanced wantMetric: displayv1.TileMetric_TILE_METRIC_PRESENCE, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, }, { - name: "boundary: entity=4 and interaction=10 (not rich, entity<5)", - room: RoomStats{RoomID: "h", EntityCount: 4, SensorCount: 0, InteractionCount30d: 10}, + name: "boundary: entity=4 and interaction=10 (not rich, entity<5)", + room: RoomStats{RoomID: "h", EntityCount: 4, SensorCount: 0, InteractionCount30d: 10}, wantScenes: 2, // entity>=3 → balanced wantMetric: displayv1.TileMetric_TILE_METRIC_PRESENCE, wantWidth: displayv1.TileWidth_TILE_WIDTH_STANDARD, diff --git a/internal/display/service.go b/internal/display/service.go index 7aa9d21c..70922a61 100644 --- a/internal/display/service.go +++ b/internal/display/service.go @@ -193,16 +193,16 @@ func generateToken() (string, error) { // displayRecord is the on-disk representation of a Display. type displayRecord struct { - ID string `json:"id"` - DeviceName string `json:"device_name"` - PageSlug string `json:"page_slug,omitempty"` + ID string `json:"id"` + DeviceName string `json:"device_name"` + PageSlug string `json:"page_slug,omitempty"` TileOverrides map[string]*displayv1.FidelityOverride `json:"tile_overrides,omitempty"` - IdleBehavior *displayv1.IdleBehavior `json:"idle_behavior,omitempty"` - AllowedInteractions []string `json:"allowed_interactions,omitempty"` - AlertThreshold displayv1.AlertThreshold `json:"alert_threshold,omitempty"` - Token string `json:"token"` - CreatedAt time.Time `json:"created_at"` - LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + IdleBehavior *displayv1.IdleBehavior `json:"idle_behavior,omitempty"` + AllowedInteractions []string `json:"allowed_interactions,omitempty"` + AlertThreshold displayv1.AlertThreshold `json:"alert_threshold,omitempty"` + Token string `json:"token"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` } func (r displayRecord) toProto() *displayv1.Display { diff --git a/internal/driver/management/service_test.go b/internal/driver/management/service_test.go index f08d96b3..9d3a53d5 100644 --- a/internal/driver/management/service_test.go +++ b/internal/driver/management/service_test.go @@ -73,20 +73,20 @@ func newTestRegistry() *fakeRegistry { return &fakeRegistry{ running: []*driverv1.DriverSummary{ { - Id: "hue-bridge", - Pack: "@switchyard/hue", - Version: "1.2.3", - Status: "healthy", + Id: "hue-bridge", + Pack: "@switchyard/hue", + Version: "1.2.3", + Status: "healthy", UptimeSeconds: 86400, - EntityCount: 42, + EntityCount: 42, }, { - Id: "z2m", - Pack: "@switchyard/z2m", - Version: "2.0.1", - Status: "reconnecting", + Id: "z2m", + Pack: "@switchyard/z2m", + Version: "2.0.1", + Status: "reconnecting", UptimeSeconds: 3600, - EntityCount: 17, + EntityCount: 17, }, }, available: []*driverv1.RegistryDriver{ diff --git a/internal/editsession/regenerability.go b/internal/editsession/regenerability.go index ae9ee566..ac594661 100644 --- a/internal/editsession/regenerability.go +++ b/internal/editsession/regenerability.go @@ -14,9 +14,9 @@ import ( // Reason constants for FileOnlyRegion.Reason. const ( - ReasonStarlarkCall = "starlark_call" - ReasonImport = "import" - ReasonLetBinding = "let_binding" + ReasonStarlarkCall = "starlark_call" + ReasonImport = "import" + ReasonLetBinding = "let_binding" ReasonNondeterministic = "nondeterministic" ) diff --git a/internal/editsession/watcher.go b/internal/editsession/watcher.go index 16a10120..a092bf1b 100644 --- a/internal/editsession/watcher.go +++ b/internal/editsession/watcher.go @@ -37,7 +37,7 @@ type FileWatcher struct { pollInterval time.Duration mu sync.Mutex - watched map[string]watchedFile // key: path + watched map[string]watchedFile // key: path subscribers []subscriber } diff --git a/internal/interestingness/configuration.go b/internal/interestingness/configuration.go index 83060bd4..c3dda81e 100644 --- a/internal/interestingness/configuration.go +++ b/internal/interestingness/configuration.go @@ -8,10 +8,10 @@ import ( // configKinds maps event kinds to tag names for the ConfigurationDetector. var configKinds = map[string]string{ - "config.applied": "config_applied", - "driver.restarted": "driver_restarted", - "automation.deployed": "automation_deployed", - "widgetpack.installed": "widgetpack_installed", + "config.applied": "config_applied", + "driver.restarted": "driver_restarted", + "automation.deployed": "automation_deployed", + "widgetpack.installed": "widgetpack_installed", } // ConfigurationDetector tags configuration-change events. diff --git a/internal/interestingness/novelty.go b/internal/interestingness/novelty.go index 7ce14e38..d133e956 100644 --- a/internal/interestingness/novelty.go +++ b/internal/interestingness/novelty.go @@ -32,9 +32,9 @@ func (c *NoveltyConfig) withDefaults() { type NoveltyDetector struct { cfg NoveltyConfig - mu sync.Mutex - seenEntities map[string]struct{} - lastCommand map[string]time.Time // command kind → last seen time + mu sync.Mutex + seenEntities map[string]struct{} + lastCommand map[string]time.Time // command kind → last seen time } // NewNoveltyDetector creates a NoveltyDetector with the given config. diff --git a/internal/registry/areas_test.go b/internal/registry/areas_test.go new file mode 100644 index 00000000..98bba1b3 --- /dev/null +++ b/internal/registry/areas_test.go @@ -0,0 +1,69 @@ +package registry_test + +import ( + "context" + "testing" + + "github.com/fdatoo/switchyard/internal/registry" + "github.com/fdatoo/switchyard/internal/testutil" +) + +func TestRegistry_AreasInMemory(t *testing.T) { + ctx := context.Background() + db := testutil.NewTestDB(t) + reg, err := registry.New(ctx, db) + if err != nil { + t.Fatal(err) + } + + reg.SetAreas([]registry.Area{ + {ID: "kitchen", DisplayName: "Kitchen"}, + {ID: "", DisplayName: "ignored"}, + {ID: "bedroom", DisplayName: "Bedroom", ParentID: "upstairs"}, + }) + + areas := reg.ListAreasInMemory() + if len(areas) != 2 { + t.Fatalf("ListAreasInMemory len = %d, want 2", len(areas)) + } + if areas[0].ID != "bedroom" || areas[1].ID != "kitchen" { + t.Fatalf("areas not sorted by ID: %+v", areas) + } + + got, ok := reg.GetAreaInMemory("bedroom") + if !ok { + t.Fatal("GetAreaInMemory(bedroom) ok = false") + } + if got.DisplayName != "Bedroom" || got.ParentID != "upstairs" { + t.Fatalf("GetAreaInMemory(bedroom) = %+v", got) + } + + if _, ok := reg.GetAreaInMemory("missing"); ok { + t.Fatal("GetAreaInMemory(missing) ok = true") + } +} + +func TestRegistry_EntityAreasInMemory(t *testing.T) { + ctx := context.Background() + db := testutil.NewTestDB(t) + reg, err := registry.New(ctx, db) + if err != nil { + t.Fatal(err) + } + + assignments := map[string]string{"light.kitchen": "kitchen"} + reg.SetEntityAreas(assignments) + assignments["light.kitchen"] = "mutated" + + if got := reg.AreaForEntity("light.kitchen"); got != "kitchen" { + t.Fatalf("AreaForEntity(light.kitchen) = %q, want kitchen", got) + } + if got := reg.AreaForEntity("light.bedroom"); got != "" { + t.Fatalf("AreaForEntity(light.bedroom) = %q, want empty", got) + } + + reg.SetEntityAreas(nil) + if got := reg.AreaForEntity("light.kitchen"); got != "" { + t.Fatalf("AreaForEntity after reset = %q, want empty", got) + } +} diff --git a/internal/replay/service.go b/internal/replay/service.go index 90836818..d777c35e 100644 --- a/internal/replay/service.go +++ b/internal/replay/service.go @@ -22,9 +22,9 @@ type EventLookup interface { // Service implements ReplayServiceHandler. type Service struct { - snaps SnapshotStore - events EventReader - byID EventLookup + snaps SnapshotStore + events EventReader + byID EventLookup // bySeq is also EventLookup — same interface } diff --git a/internal/replay/service_test.go b/internal/replay/service_test.go index 838e5ddc..eab2d970 100644 --- a/internal/replay/service_test.go +++ b/internal/replay/service_test.go @@ -25,33 +25,33 @@ func makeTestStore() *mockStore { }, events: []EntityEvent{ { - Seq: 101, - EntityID: "light.kitchen", - Fields: map[string]string{"brightness": "18"}, - EventID: "evt_101", - Kind: "state.updated", - Source: "driver.hue", - OccurredAt: t0, - }, - { - Seq: 102, + Seq: 101, EntityID: "light.kitchen", - Fields: map[string]string{"brightness": "64"}, - EventID: "evt_102", + Fields: map[string]string{"brightness": "18"}, + EventID: "evt_101", Kind: "state.updated", Source: "driver.hue", + OccurredAt: t0, + }, + { + Seq: 102, + EntityID: "light.kitchen", + Fields: map[string]string{"brightness": "64"}, + EventID: "evt_102", + Kind: "state.updated", + Source: "driver.hue", CausationID: "evt_101", - OccurredAt: t0.Add(time.Second), + OccurredAt: t0.Add(time.Second), }, { - Seq: 103, - EntityID: "light.kitchen", - Fields: map[string]string{"brightness": "80"}, - EventID: "evt_103", - Kind: "state.updated", - Source: "driver.hue", + Seq: 103, + EntityID: "light.kitchen", + Fields: map[string]string{"brightness": "80"}, + EventID: "evt_103", + Kind: "state.updated", + Source: "driver.hue", CausationID: "evt_102", - OccurredAt: t0.Add(2 * time.Second), + OccurredAt: t0.Add(2 * time.Second), }, }, causationMap: map[string]string{ diff --git a/internal/replay/snapshots.go b/internal/replay/snapshots.go index d3f1ceed..6ff0a727 100644 --- a/internal/replay/snapshots.go +++ b/internal/replay/snapshots.go @@ -23,15 +23,15 @@ type EntityEvent struct { EntityID string Fields map[string]string // full field set after the event is applied // Metadata (populated by EventReader implementations) - EventID string - Kind string - Source string - CausationID string - CorrelationID string - Emitter string - SpanID string - OccurredAt time.Time - PayloadJSON string + EventID string + Kind string + Source string + CausationID string + CorrelationID string + Emitter string + SpanID string + OccurredAt time.Time + PayloadJSON string WhyInteresting string } diff --git a/internal/starlarkls/diagnose.go b/internal/starlarkls/diagnose.go index aa594a6a..fbe719d5 100644 --- a/internal/starlarkls/diagnose.go +++ b/internal/starlarkls/diagnose.go @@ -4,8 +4,9 @@ import ( "fmt" "path/filepath" - starlarkpb "github.com/fdatoo/switchyard/gen/switchyard/starlarkls/v1" "go.starlark.net/syntax" + + starlarkpb "github.com/fdatoo/switchyard/gen/switchyard/starlarkls/v1" ) // Diagnostic is the daemon-internal representation of a Starlark editor @@ -69,10 +70,10 @@ func Diagnose(filePath, source string, symbols map[string]SymbolInfo, predeclare path := load.ModuleName() if !loadablePaths[path] && !loadablePaths[filepath.Base(path)] { diags = append(diags, Diagnostic{ - StartLine: int32(load.Module.TokenPos.Line), - StartCol: int32(load.Module.TokenPos.Col - 1), - EndLine: int32(load.Module.TokenPos.Line), - EndCol: int32(load.Module.TokenPos.Col-1) + int32(len(load.Module.Raw)), + StartLine: load.Module.TokenPos.Line, + StartCol: load.Module.TokenPos.Col - 1, + EndLine: load.Module.TokenPos.Line, + EndCol: load.Module.TokenPos.Col - 1 + int32(len(load.Module.Raw)), Severity: "error", Message: fmt.Sprintf("load: file %q not found in scripts directory", path), Code: "load_not_found", @@ -95,10 +96,10 @@ func Diagnose(filePath, source string, symbols map[string]SymbolInfo, predeclare func parseDiagnostic(err error) Diagnostic { if se, ok := err.(syntax.Error); ok { return Diagnostic{ - StartLine: int32(se.Pos.Line), - StartCol: int32(max(se.Pos.Col-1, 0)), - EndLine: int32(se.Pos.Line), - EndCol: int32(max(se.Pos.Col, 1)), + StartLine: se.Pos.Line, + StartCol: max(se.Pos.Col-1, 0), + EndLine: se.Pos.Line, + EndCol: max(se.Pos.Col, 1), Severity: "error", Message: se.Msg, Code: "parse_error", @@ -173,10 +174,10 @@ func (w *refWalker) isResolved(name string, locals []map[string]bool) bool { func (w *refWalker) flag(id *syntax.Ident) { *w.diags = append(*w.diags, Diagnostic{ - StartLine: int32(id.NamePos.Line), - StartCol: int32(id.NamePos.Col - 1), - EndLine: int32(id.NamePos.Line), - EndCol: int32(id.NamePos.Col-1) + int32(len(id.Name)), + StartLine: id.NamePos.Line, + StartCol: id.NamePos.Col - 1, + EndLine: id.NamePos.Line, + EndCol: id.NamePos.Col - 1 + int32(len(id.Name)), Severity: "warning", Message: fmt.Sprintf("unresolved name %q", id.Name), Code: "unresolved_name", @@ -192,7 +193,7 @@ func (w *refWalker) walkStmt(stmt syntax.Stmt, locals []map[string]bool) { w.walkParamDefault(p, locals) } collectBodyBindings(s.Body, scope) - nested := append(locals, scope) + nested := appendScope(locals, scope) for _, bs := range s.Body { w.walkStmt(bs, nested) } @@ -213,7 +214,7 @@ func (w *refWalker) walkStmt(stmt syntax.Stmt, locals []map[string]bool) { case *syntax.ForStmt: scope := map[string]bool{} collectAssignTargets(s.Vars, scope) - nested := append(locals, scope) + nested := appendScope(locals, scope) w.walkExpr(s.X, locals) for _, bs := range s.Body { w.walkStmt(bs, nested) @@ -227,6 +228,12 @@ func (w *refWalker) walkStmt(stmt syntax.Stmt, locals []map[string]bool) { } } +func appendScope(locals []map[string]bool, scope map[string]bool) []map[string]bool { + nested := make([]map[string]bool, len(locals), len(locals)+1) + copy(nested, locals) + return append(nested, scope) +} + func collectBodyBindings(stmts []syntax.Stmt, out map[string]bool) { for _, stmt := range stmts { switch s := stmt.(type) { @@ -319,7 +326,7 @@ func (w *refWalker) walkCallArg(expr syntax.Expr, locals []map[string]bool) { func (w *refWalker) walkComprehension(expr *syntax.Comprehension, locals []map[string]bool) { scope := map[string]bool{} - nested := append(locals, scope) + nested := appendScope(locals, scope) for _, clause := range expr.Clauses { switch c := clause.(type) { case *syntax.ForClause: diff --git a/internal/starlarkls/service.go b/internal/starlarkls/service.go index ffb678f4..c2542be7 100644 --- a/internal/starlarkls/service.go +++ b/internal/starlarkls/service.go @@ -6,6 +6,7 @@ import ( "strings" "connectrpc.com/connect" + starlarkpb "github.com/fdatoo/switchyard/gen/switchyard/starlarkls/v1" "github.com/fdatoo/switchyard/gen/switchyard/starlarkls/v1/starlarklsv1connect" ) diff --git a/internal/starlarkls/service_test.go b/internal/starlarkls/service_test.go index 07164f8c..32d4c966 100644 --- a/internal/starlarkls/service_test.go +++ b/internal/starlarkls/service_test.go @@ -7,6 +7,7 @@ import ( "testing" "connectrpc.com/connect" + starlarkpb "github.com/fdatoo/switchyard/gen/switchyard/starlarkls/v1" "github.com/fdatoo/switchyard/gen/switchyard/starlarkls/v1/starlarklsv1connect" "github.com/fdatoo/switchyard/internal/starlarkls" diff --git a/internal/starlarkls/symbols.go b/internal/starlarkls/symbols.go index 9c8487b7..31c7a7a9 100644 --- a/internal/starlarkls/symbols.go +++ b/internal/starlarkls/symbols.go @@ -39,7 +39,7 @@ func ExtractSymbols(dir string) (map[string]SymbolInfo, error) { doc := extractDoc(s) out[s.Name.Name] = SymbolInfo{ File: path, - Line: int32(s.Name.NamePos.Line), + Line: s.Name.NamePos.Line, Kind: "function", Doc: doc, } @@ -47,7 +47,7 @@ func ExtractSymbols(dir string) (map[string]SymbolInfo, error) { if id, ok := s.LHS.(*syntax.Ident); ok && strings.ToUpper(id.Name) == id.Name && id.Name != "_" { out[id.Name] = SymbolInfo{ File: path, - Line: int32(id.NamePos.Line), + Line: id.NamePos.Line, Kind: "global", } } diff --git a/internal/widgetpack/service.go b/internal/widgetpack/service.go index 1819ef44..169295e1 100644 --- a/internal/widgetpack/service.go +++ b/internal/widgetpack/service.go @@ -7,9 +7,9 @@ import ( "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" + pagev1 "github.com/fdatoo/switchyard/gen/switchyard/page/v1" v1 "github.com/fdatoo/switchyard/gen/switchyard/v1alpha1" "github.com/fdatoo/switchyard/gen/switchyard/v1alpha1/switchyardv1alpha1connect" - pagev1 "github.com/fdatoo/switchyard/gen/switchyard/page/v1" ) // Service implements WidgetPackServiceHandler. From 9e9877d3617fd7004f117c45d5fba30b7536fa81 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 14 May 2026 00:24:17 -0700 Subject: [PATCH 3/3] Stabilize interestingness pipeline integration test --- internal/interestingness/pipeline_test.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/interestingness/pipeline_test.go b/internal/interestingness/pipeline_test.go index aa768391..a1bd534a 100644 --- a/internal/interestingness/pipeline_test.go +++ b/internal/interestingness/pipeline_test.go @@ -84,19 +84,16 @@ func TestPipeline_AppendsTwoTaggedEventsForTwoFailures(t *testing.T) { _, err = store.Append(ctx, failureEvent()) require.NoError(t, err) - // Give the pipeline a moment to process. - time.Sleep(200 * time.Millisecond) + require.Eventually(t, func() bool { + events, err := store.Query(ctx, eventstore.QueryOptions{ + Filter: eventstore.Filter{Kinds: []string{"interestingness.tagged"}}, + }) + require.NoError(t, err) + return len(events) == 2 + }, 5*time.Second, 25*time.Millisecond, "expected exactly 2 interestingness.tagged events") - // Cancel and wait for pipeline. pipelineCancel() <-pipelineDone - - // Query for interestingness.tagged events. - events, err := store.Query(ctx, eventstore.QueryOptions{ - Filter: eventstore.Filter{Kinds: []string{"interestingness.tagged"}}, - }) - require.NoError(t, err) - assert.Equal(t, 2, len(events), "expected exactly 2 interestingness.tagged events") } // TestPipeline_NoTagsForNonInterestingEvents verifies that events not matching