diff --git a/cmd/build.go b/cmd/build.go index 267fbdfea..c1ce808f4 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -90,7 +90,16 @@ field is updated to reference the built image.`, return fmt.Errorf("no container runtime found (tried docker, podman)") } - outputImage := harnessConfigName + ":" + tag + imageBaseName := harnessConfigName + if hcDir.Config.Image != "" { + name := hcDir.Config.Image + if colonIdx := strings.LastIndex(name, ":"); colonIdx >= 0 { + name = name[:colonIdx] + } + imageBaseName = name + } + + outputImage := imageBaseName + ":" + tag if buildPush { imageRegistry := "" if settings != nil { @@ -99,7 +108,7 @@ field is updated to reference the built image.`, if imageRegistry == "" { return fmt.Errorf("--push requires image_registry to be configured") } - outputImage = imageRegistry + "/" + harnessConfigName + ":" + tag + outputImage = imageRegistry + "/" + imageBaseName + ":" + tag } buildArgs := []string{"build", @@ -167,6 +176,26 @@ field is updated to reference the built image.`, } fmt.Printf("Updated %s image to %s\n", configPath, outputImage) + // Sync updated config to Hub so agents pick up the new image. + var gp string + if projectPath != "" { + if resolved, err := config.GetResolvedProjectDir(projectPath); err == nil { + gp = resolved + } + } else if resolved, err := config.GetResolvedProjectDir(""); err == nil { + gp = resolved + } + hubCtx, hubErr := CheckHubAvailabilityWithOptions(gp, true) + if hubErr != nil { + fmt.Printf("Warning: could not sync to Hub: %v\n", hubErr) + fmt.Println("Run 'scion harness-config push " + harnessConfigName + "' to sync manually.") + } else if hubCtx != nil { + if err := syncHarnessConfigToHub(hubCtx, harnessConfigName, hcDir.Path, "global", "", hcDir.Config.Harness); err != nil { + fmt.Printf("Warning: failed to sync to Hub: %v\n", err) + fmt.Println("Run 'scion harness-config push " + harnessConfigName + "' to sync manually.") + } + } + return nil }, } diff --git a/cmd/harness_config.go b/cmd/harness_config.go index 0fe3ef654..5b150ab84 100644 --- a/cmd/harness_config.go +++ b/cmd/harness_config.go @@ -441,6 +441,9 @@ var harnessConfigShowCmd = &cobra.Command{ fmt.Printf("Content Hash: %s\n", truncateHash(hc.ContentHash)) fmt.Printf("Scope: %s\n", hc.Scope) fmt.Printf("Files: %d\n", len(hc.Files)) + if hc.SourceURL != "" { + fmt.Printf("Source URL: %s\n", hc.SourceURL) + } return nil } } @@ -834,6 +837,7 @@ func init() { harnessConfigCmd.AddCommand(harnessConfigShowCmd) harnessConfigCmd.AddCommand(harnessConfigDeleteCmd) harnessConfigCmd.AddCommand(harnessConfigInstallCmd) + harnessConfigCmd.AddCommand(harnessConfigUpdateCmd) // Flags for list command harnessConfigListCmd.Flags().Bool("hub", false, "Include Hub results") diff --git a/cmd/harness_config_install.go b/cmd/harness_config_install.go index 57e5ee59e..391b6ad3a 100644 --- a/cmd/harness_config_install.go +++ b/cmd/harness_config_install.go @@ -80,10 +80,16 @@ func runHarnessConfigInstall(cmd *cobra.Command, args []string) error { name := nameOverride if name == "" { - name = deriveHarnessConfigName(source) + if hcDir.Config.Name != "" { + name = hcDir.Config.Name + } else if hcDir.Config.Harness != "" { + name = hcDir.Config.Harness + } else { + name = deriveHarnessConfigName(source) + } } - if name == "" || name == "." || name == "/" { - return fmt.Errorf("could not derive harness-config name from source %q; use --name to specify", source) + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") { + return fmt.Errorf("invalid harness-config name %q; name cannot contain path components or separators", name) } hubCtx, err := CheckHubAvailabilityWithOptions(gp, true) diff --git a/cmd/harness_config_update.go b/cmd/harness_config_update.go new file mode 100644 index 000000000..b2cde3f53 --- /dev/null +++ b/cmd/harness_config_update.go @@ -0,0 +1,197 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/hubclient" + "github.com/spf13/cobra" +) + +var harnessConfigUpdateCmd = &cobra.Command{ + Use: "update [name]", + Short: "Re-import a harness-config from its source URL", + Long: `Re-imports a harness-config from its stored source URL, pulling the latest +version from the remote source. + +If --url is provided, it overrides (and updates) the stored source URL. +Use --all to re-import all harness-configs that have a stored source URL. + +Examples: + scion harness-config update my-claude + scion harness-config update my-claude --url https://github.com/org/repo/tree/main/harness-configs/claude + scion harness-config update --all`, + Args: cobra.MaximumNArgs(1), + RunE: runHarnessConfigUpdate, +} + +func runHarnessConfigUpdate(cmd *cobra.Command, args []string) error { + urlOverride, _ := cmd.Flags().GetString("url") + all, _ := cmd.Flags().GetBool("all") + + if len(args) == 0 && !all { + return fmt.Errorf("specify a harness-config name or use --all") + } + if all && urlOverride != "" { + return fmt.Errorf("--all and --url cannot be used together") + } + + var gp string + if projectPath != "" { + resolved, err := config.GetResolvedProjectDir(projectPath) + if err != nil { + return fmt.Errorf("failed to resolve project path %q: %w", projectPath, err) + } + gp = resolved + } else if projectDir, err := config.GetResolvedProjectDir(""); err == nil { + gp = projectDir + } + + hubCtx, err := CheckHubAvailabilityWithOptions(gp, true) + if err != nil { + return err + } + if hubCtx == nil { + return fmt.Errorf("Hub is not available. The update command requires a Hub connection.") + } + + PrintUsingHub(hubCtx.Endpoint) + + ctx, cancel := context.WithTimeout(cmd.Context(), 120*time.Second) + defer cancel() + + if all { + return updateAllHarnessConfigs(ctx, hubCtx) + } + + name := args[0] + return updateSingleHarnessConfig(ctx, hubCtx, name, urlOverride) +} + +func updateSingleHarnessConfig(ctx context.Context, hubCtx *HubContext, name, urlOverride string) error { + resp, err := hubCtx.Client.HarnessConfigs().List(ctx, &hubclient.ListHarnessConfigsOptions{ + Name: name, + Status: "active", + }) + if err != nil { + return fmt.Errorf("failed to search Hub: %w", err) + } + if resp == nil { + return fmt.Errorf("harness-config %q not found on Hub", name) + } + + var match *hubclient.HarnessConfig + for i := range resp.HarnessConfigs { + if resp.HarnessConfigs[i].Name == name || resp.HarnessConfigs[i].Slug == name { + match = &resp.HarnessConfigs[i] + break + } + } + if match == nil { + return fmt.Errorf("harness-config %q not found on Hub", name) + } + + sourceURL := urlOverride + if sourceURL == "" { + sourceURL = match.SourceURL + } + if sourceURL == "" { + return fmt.Errorf("harness-config %q has no stored source URL. Use --url to specify one.", name) + } + + if !isJSONOutput() { + fmt.Printf("Updating %q from %s...\n", name, sourceURL) + } + + result, err := hubCtx.Client.HarnessConfigs().Reimport(ctx, match.ID, urlOverride) + if err != nil { + return fmt.Errorf("reimport failed: %w", err) + } + if result == nil { + return fmt.Errorf("reimport returned no result for %q", name) + } + + if isJSONOutput() { + return outputJSON(ActionResult{ + Status: "success", + Command: "harness-config update", + Message: fmt.Sprintf("Updated %d harness-config(s)", result.Count), + Details: map[string]interface{}{ + "name": name, + "imported": result.HarnessConfigs, + "count": result.Count, + }, + }) + } + + fmt.Printf("Updated %d harness-config(s): %v\n", result.Count, result.HarnessConfigs) + return nil +} + +func updateAllHarnessConfigs(ctx context.Context, hubCtx *HubContext) error { + resp, err := hubCtx.Client.HarnessConfigs().List(ctx, &hubclient.ListHarnessConfigsOptions{ + Status: "active", + }) + if err != nil { + return fmt.Errorf("failed to list harness-configs: %w", err) + } + if resp == nil { + return fmt.Errorf("no harness-configs found") + } + + var updated, skipped, failed int + jsonOut := isJSONOutput() + for _, hc := range resp.HarnessConfigs { + if hc.SourceURL == "" { + skipped++ + continue + } + if !jsonOut { + fmt.Printf("Updating %q from %s...\n", hc.Name, hc.SourceURL) + } + _, err := hubCtx.Client.HarnessConfigs().Reimport(ctx, hc.ID, "") + if err != nil { + if !jsonOut { + fmt.Printf(" Failed: %s\n", err) + } + failed++ + continue + } + if !jsonOut { + fmt.Printf(" Done.\n") + } + updated++ + } + + if jsonOut { + return outputJSON(ActionResult{ + Status: "success", + Command: "harness-config update --all", + Message: fmt.Sprintf("Updated %d, skipped %d (no source URL), failed %d", updated, skipped, failed), + }) + } + + fmt.Printf("\nUpdated %d, skipped %d (no source URL), failed %d\n", updated, skipped, failed) + return nil +} + +func init() { + harnessConfigUpdateCmd.Flags().String("url", "", "Override the stored source URL") + harnessConfigUpdateCmd.Flags().Bool("all", false, "Update all harness-configs that have a stored source URL") +} diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index e7644e5b6..3fd6bdc5c 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -54,6 +54,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/store/entadapter" "github.com/GoogleCloudPlatform/scion/pkg/util" "github.com/GoogleCloudPlatform/scion/pkg/util/logging" + "github.com/GoogleCloudPlatform/scion/web" "github.com/spf13/cobra" ) @@ -328,6 +329,9 @@ func runServerStart(cmd *cobra.Command, args []string) error { hubSrv.StartBackgroundServices(ctx) } + if !web.AssetsEmbedded && webAssetsDir == "" { + slog.Warn("This binary was built without web assets. The web UI will not be available. Run 'make web' and rebuild to include the web frontend, or use --web-assets-dir.") + } log.Printf("Starting Web Frontend on %s:%d", cfg.Hub.Host, webPort) wg.Add(1) go func() { diff --git a/cmd/skill_registries.go b/cmd/skill_registries.go index 97a4331f1..44194d738 100644 --- a/cmd/skill_registries.go +++ b/cmd/skill_registries.go @@ -44,7 +44,7 @@ func runRegistriesList(cmd *cobra.Command, args []string) error { return fmt.Errorf("Hub connection required: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() resp, err := hubCtx.Client.SkillRegistries().List(ctx) @@ -83,7 +83,7 @@ func runRegistriesAdd(cmd *cobra.Command, args []string) error { return fmt.Errorf("Hub connection required: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() endpoint, _ := cmd.Flags().GetString("endpoint") @@ -133,7 +133,7 @@ func runRegistriesShow(cmd *cobra.Command, args []string) error { return fmt.Errorf("Hub connection required: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() registry, err := hubCtx.Client.SkillRegistries().Get(ctx, args[0]) @@ -175,7 +175,7 @@ func runRegistriesUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("Hub connection required: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() endpoint, _ := cmd.Flags().GetString("endpoint") @@ -185,13 +185,24 @@ func runRegistriesUpdate(cmd *cobra.Command, args []string) error { authToken, _ := cmd.Flags().GetString("auth-token") resolvePath, _ := cmd.Flags().GetString("resolve-path") - req := &hubclient.UpdateSkillRegistryRequest{ - Endpoint: endpoint, - TrustLevel: trust, - Status: status, - Description: description, - AuthToken: authToken, - ResolvePath: resolvePath, + req := &hubclient.UpdateSkillRegistryRequest{} + if cmd.Flags().Changed("endpoint") { + req.Endpoint = &endpoint + } + if cmd.Flags().Changed("trust") { + req.TrustLevel = &trust + } + if cmd.Flags().Changed("status") { + req.Status = &status + } + if cmd.Flags().Changed("description") { + req.Description = &description + } + if cmd.Flags().Changed("auth-token") { + req.AuthToken = &authToken + } + if cmd.Flags().Changed("resolve-path") { + req.ResolvePath = &resolvePath } registry, err := hubCtx.Client.SkillRegistries().Update(ctx, args[0], req) @@ -220,7 +231,7 @@ func runRegistriesRemove(cmd *cobra.Command, args []string) error { return fmt.Errorf("Hub connection required: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() if err := hubCtx.Client.SkillRegistries().Delete(ctx, args[0]); err != nil { @@ -248,7 +259,7 @@ func runRegistriesPin(cmd *cobra.Command, args []string) error { return fmt.Errorf("Hub connection required: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() hash, _ := cmd.Flags().GetString("hash") diff --git a/docs-site/package-lock.json b/docs-site/package-lock.json index 4587e4c21..739d42555 100644 --- a/docs-site/package-lock.json +++ b/docs-site/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@astrojs/check": "^0.9.6", "@astrojs/starlight": "^0.38.3", - "astro": "^6.3.2", + "astro": "^6.4.8", "sharp": "^0.34.2", "starlight-links-validator": "^0.24.0", "typescript": "^5.9.3" @@ -2378,14 +2378,14 @@ } }, "node_modules/astro": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-6.3.2.tgz", - "integrity": "sha512-Wvl/420m99OjKRH9Q+Vk7JBed8H39n66R61FG2ty0yZjQXBplIIXvJWYXUouMn2U4znfjpYe1HLOA2Rpet6uog==", + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.4.8.tgz", + "integrity": "sha512-KK5lX90uU9EeVaTjINyj3sy9/NFXVa59aowaqbWBDDKLXZh4rr7GwIaCFYVetE22MJtsCNFerQXn0vlCLmpP/Q==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^4.0.0", - "@astrojs/internal-helpers": "0.9.1", - "@astrojs/markdown-remark": "7.1.2", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/markdown-remark": "7.2.0", "@astrojs/telemetry": "3.3.2", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", @@ -2397,7 +2397,7 @@ "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", - "devalue": "^5.6.3", + "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", @@ -2493,26 +2493,32 @@ "license": "MIT" }, "node_modules/astro/node_modules/@astrojs/internal-helpers": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.1.tgz", - "integrity": "sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.0.tgz", + "integrity": "sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==", "license": "MIT", "dependencies": { - "picomatch": "^4.0.4" + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" } }, "node_modules/astro/node_modules/@astrojs/markdown-remark": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.2.tgz", - "integrity": "sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.0.tgz", + "integrity": "sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.9.1", + "@astrojs/internal-helpers": "0.10.0", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", - "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", @@ -2520,9 +2526,6 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.0", - "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", @@ -2543,13 +2546,13 @@ } }, "node_modules/astro/node_modules/@shikijs/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", - "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.2.0.tgz", + "integrity": "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.0.2", - "@shikijs/types": "4.0.2", + "@shikijs/primitive": "4.2.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" @@ -2559,26 +2562,26 @@ } }, "node_modules/astro/node_modules/@shikijs/engine-javascript": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", - "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.2.0.tgz", + "integrity": "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" + "oniguruma-to-es": "^4.3.6" }, "engines": { "node": ">=20" } }, "node_modules/astro/node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.2.0.tgz", + "integrity": "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -2586,33 +2589,47 @@ } }, "node_modules/astro/node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.2.0.tgz", + "integrity": "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2" + "@shikijs/types": "4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/astro/node_modules/@shikijs/primitive": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.2.0.tgz", + "integrity": "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.2.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" }, "engines": { "node": ">=20" } }, "node_modules/astro/node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.2.0.tgz", + "integrity": "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2" + "@shikijs/types": "4.2.0" }, "engines": { "node": ">=20" } }, "node_modules/astro/node_modules/@shikijs/types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", - "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.2.0.tgz", + "integrity": "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -2629,17 +2646,17 @@ "license": "MIT" }, "node_modules/astro/node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz", + "integrity": "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", + "@shikijs/core": "4.2.0", + "@shikijs/engine-javascript": "4.2.0", + "@shikijs/engine-oniguruma": "4.2.0", + "@shikijs/langs": "4.2.0", + "@shikijs/themes": "4.2.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -4242,9 +4259,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5522,19 +5549,19 @@ "license": "MIT" }, "node_modules/oniguruma-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", - "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "license": "MIT", "dependencies": { - "oniguruma-parser": "^0.12.1", - "regex": "^6.0.1", + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, diff --git a/docs-site/package.json b/docs-site/package.json index 7c4a59906..b6d1aab6e 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/check": "^0.9.6", "@astrojs/starlight": "^0.38.3", - "astro": "^6.3.2", + "astro": "^6.4.8", "sharp": "^0.34.2", "starlight-links-validator": "^0.24.0", "typescript": "^5.9.3" diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md index b50a161db..c9f63f0eb 100644 --- a/docs/lifecycle-hooks.md +++ b/docs/lifecycle-hooks.md @@ -173,6 +173,7 @@ They may appear in the URL, headers, and body. | `TRIGGER` | Trigger that fired (`running`, etc.) | | `PROJECT_ID` | Agent's project ID | | `PROJECT_NAME` | Agent's project name | +| `PROJECT_SLUG` | Agent's project slug | | `AGENT_ID` | Agent record ID | | `AGENT_SLUG` | Agent slug (hub-controlled) | | `SA_EMAIL` | Resolved SA email | @@ -341,3 +342,69 @@ starts and deregister it when it stops. You may also add deregister hooks for the `suspended` and `error` triggers to ensure agents are removed from the registry in all terminal/inactive states. + +## Example: Google Cloud Agent Registry Integration + +Lifecycle hooks can register agents as A2A endpoints in the +[Google Cloud Agent Registry](https://docs.cloud.google.com/agent-registry/manual-registration). +When an agent starts, a hook creates a Service in Agent Registry with +an A2A Agent Card pointing to the agent's specific endpoint on the A2A +Bridge. When the agent stops, another hook deletes the Service. + +**Prerequisites:** + +- A GCP service account with the `agentregistry.editor` role on the + target project, registered as a managed SA in the Hub. +- The A2A Bridge deployed and accessible at an external URL. + +**Register hook** (fires on `running`): + +The hook body constructs an A2A Agent Card with the per-agent endpoint +URL from the A2A Bridge using the `PROJECT_SLUG` and `AGENT_SLUG` +variables. The `serviceId` query parameter uses a deterministic name +so the same hook can cleanly deregister. + +```json +{ + "name": "agent-registry-register", + "scopeType": "hub", + "trigger": "running", + "action": { + "type": "http", + "method": "POST", + "url": "https://agentregistry.googleapis.com/v1alpha/projects//locations//services?serviceId=scion-${PROJECT_SLUG}-${AGENT_SLUG}", + "headers": { "Content-Type": "application/json" }, + "body": "{\"displayName\":\"Scion Agent: ${PROJECT_SLUG}/${AGENT_SLUG}\",\"agentSpec\":{\"type\":\"A2A_AGENT_CARD\",\"content\":{\"name\":\"${AGENT_SLUG}\",\"description\":\"Scion agent ${AGENT_SLUG} in project ${PROJECT_SLUG}\",\"version\":\"1.0.0\",\"supportedInterfaces\":[{\"url\":\"https:///projects/${PROJECT_SLUG}/agents/${AGENT_SLUG}\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true,\"pushNotifications\":true},\"defaultInputModes\":[\"text/plain\",\"application/json\"],\"defaultOutputModes\":[\"text/plain\",\"application/json\"],\"skills\":[{\"id\":\"${AGENT_SLUG}\",\"name\":\"${AGENT_SLUG}\",\"description\":\"Interact with agent ${AGENT_SLUG}\",\"tags\":[\"scion\",\"a2a\"]}],\"provider\":{\"organization\":\"Scion\",\"url\":\"https://github.com/ptone/scion\"}}}}", + "onError": "retry", + "timeoutSeconds": 15 + }, + "executionIdentity": "", + "enabled": true +} +``` + +**Deregister hook** (fires on `stopped`): + +```json +{ + "name": "agent-registry-deregister", + "scopeType": "hub", + "trigger": "stopped", + "action": { + "type": "http", + "method": "DELETE", + "url": "https://agentregistry.googleapis.com/v1alpha/projects//locations//services/scion-${PROJECT_SLUG}-${AGENT_SLUG}", + "headers": { "Content-Type": "application/json" }, + "onError": "retry", + "timeoutSeconds": 15 + }, + "executionIdentity": "", + "enabled": true +} +``` + +Add similar deregister hooks for the `suspended` and `error` triggers +to ensure agents are removed from Agent Registry in all inactive states. + +Replace ``, ``, ``, and +`` with your actual values. diff --git a/go.mod b/go.mod index 1473903af..4b6e5b719 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,17 @@ go 1.26.1 require ( cloud.google.com/go/compute/metadata v0.9.0 cloud.google.com/go/logging v1.13.2 + cloud.google.com/go/monitoring v1.24.3 cloud.google.com/go/secretmanager v1.16.0 cloud.google.com/go/storage v1.59.1 entgo.io/ent v0.14.5 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.31.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/creack/pty v1.1.24 github.com/dustin/go-humanize v1.0.1 github.com/go-jose/go-jose/v4 v4.1.4 github.com/google/uuid v1.6.0 - github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-hclog v1.6.3 @@ -27,7 +28,7 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/providers/rawbytes v1.0.0 github.com/knadh/koanf/v2 v2.3.0 - github.com/rclone/rclone v1.73.5 + github.com/rclone/rclone v1.74.3 github.com/robfig/cron/v3 v3.0.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 @@ -45,14 +46,14 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 go.opentelemetry.io/proto/otlp v1.10.0 - golang.org/x/net v0.52.0 - golang.org/x/oauth2 v0.35.0 + golang.org/x/net v0.55.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 - golang.org/x/term v0.41.0 - golang.org/x/text v0.35.0 - google.golang.org/api v0.259.0 - google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 + golang.org/x/sys v0.45.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 + google.golang.org/api v0.275.0 + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 @@ -67,32 +68,30 @@ require ( ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect - cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/trace v1.11.7 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect - github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 // indirect + github.com/aalpar/deheap v1.1.2 // indirect github.com/abbot/go-http-auth v0.4.0 // indirect + github.com/adrg/xdg v0.5.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect @@ -110,14 +109,15 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-test/deep v1.0.8 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect - github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/gopherjs/gopherjs v1.20.1 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect @@ -132,11 +132,11 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/lanrat/extsort v1.4.2 // indirect - github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.22 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect @@ -144,6 +144,7 @@ require ( github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -155,17 +156,17 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rfjakob/eme v1.1.2 // indirect - github.com/shirou/gopsutil/v4 v4.25.10 // indirect + github.com/rfjakob/eme v1.2.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/smarty/assertions v1.16.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/tklauser/go-sysconf v0.3.15 // indirect - github.com/tklauser/numcpus v0.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/unknwon/goconfig v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect @@ -174,18 +175,18 @@ require ( github.com/zclconf/go-cty-yaml v1.1.0 // indirect github.com/zeebo/assert v1.3.1 // indirect github.com/zeebo/blake3 v0.2.4 // indirect - github.com/zeebo/xxh3 v1.0.2 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -196,6 +197,7 @@ require ( modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + moul.io/http2curl/v2 v2.3.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index f80e75017..d4c6aab27 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= -cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -26,26 +26,26 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8= -github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE= -github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= +github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 h1:tZh20RjgfMxKBxJiIS75iTVAKIUxrST5X2dVHMTptL4= +github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4/go.mod h1:vGYAk36rhMVCfTP7v+RVruCR0zmPe6S+36KRpDCLySw= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/FilenCloudDienste/filen-sdk-go v0.0.38 h1:YCyHs3wUXEe2BEWn40vcoyaQ2ruHNmNwkasfo3Th16A= -github.com/FilenCloudDienste/filen-sdk-go v0.0.38/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0= -github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw= -github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE= +github.com/FilenCloudDienste/filen-sdk-go v0.0.39 h1:tgV5jYL6dsXop9TpDTIQU6UwJjws122HrwskaEE/igY= +github.com/FilenCloudDienste/filen-sdk-go v0.0.39/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0= +github.com/Files-com/files-sdk-go/v3 v3.3.82 h1:2RfP0d2QgkFH64BjZSWd59aMsc28IyWsUuHqU0txBtY= +github.com/Files-com/files-sdk-go/v3 v3.3.82/go.mod h1:IPk80dOmc7VFC0DJ85xMTPmre+8xoXX6kGHAkf5jRRw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= @@ -56,10 +56,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/IBM/go-sdk-core/v5 v5.18.5 h1:g0JRl3sYXJczB/yuDlrN6x22LJ6jIxhp0Sa4ARNW60c= -github.com/IBM/go-sdk-core/v5 v5.18.5/go.mod h1:KonTFRR+8ZSgw5cxBSYo6E4WZoY1+7n1kfHM82VcjFU= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/IBM/go-sdk-core/v5 v5.21.2 h1:mJ5QbLPOm4g5qhZiVB6wbSllfpeUExftGoyPek2hk4M= +github.com/IBM/go-sdk-core/v5 v5.21.2/go.mod h1:ngpMgwkjur1VNUjqn11LPk3o5eCyOCRbcfg/0YAY7Hc= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE= @@ -70,22 +68,24 @@ github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo= github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk= github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI= github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk= github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE= github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s= -github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= -github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak= github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q= -github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 h1:hhdWprfSpFbN7lz3W1gM40vOgvSh1WCSMxYD6gGB4Hs= -github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3/go.mod h1:XaUnRxSCYgL3kkgX0QHIV0D+znljPIDImxlv2kbGv0Y= +github.com/aalpar/deheap v1.1.2 h1:MABHLcnjqsffb8GLkUFDigqpBBxOMz0DoKM9QfELeTw= +github.com/aalpar/deheap v1.1.2/go.mod h1:A+nfkD4JbS05sewV0he/MYgR/90vfqyMoNNROgs+rmA= github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0= github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= @@ -102,20 +102,20 @@ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= -github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= -github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 h1:IbWiN670htmBioc+Zj32vSpJgQ2+OYSlvTvfQ1nCORQ= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1/go.mod h1:tw/B596EUhBWDFGdDGuLC21fVU4A3s4/5Efy8S39W18= +github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= +github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13 h1:uMC4oL6G3MNhodo358QEqSDjrgvzV3TUQ58nyQSGq2E= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13/go.mod h1:Cer86AE2686DvVUe57LPve3jUBmbujuaonSX8pNzGgw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= @@ -126,18 +126,18 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3x github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg= +github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -164,14 +164,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 h1:z0uK8UQqjMVYzvk4tiiu3obv2B44+XBsvgEJREQfnO8= github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9/go.mod h1:Jl2neWsQaDanWORdqZ4emBl50J4/aRBBS4FyyG9/PFo= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs= -github.com/cloudinary/cloudinary-go/v2 v2.13.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo= +github.com/cloudinary/cloudinary-go/v2 v2.15.0 h1:iLoIwb7BJECHTbNcmIhYDsQhoZiACWGNvEpyqQy97Dk= +github.com/cloudinary/cloudinary-go/v2 v2.15.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo= github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg= github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA= github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs= @@ -190,8 +188,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= -github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo= -github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= +github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 h1:tLWBZgPg6TV67oe76W4p+aUQEWIa52wbcuiz8GFd3vo= +github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -206,8 +204,8 @@ github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXI github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= @@ -233,16 +231,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= -github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64= github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -253,8 +251,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM= -github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= +github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= +github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -271,24 +269,24 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= -github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -310,10 +308,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= -github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gopherjs/gopherjs v1.20.1 h1:22uLWFvVcxhJ+j3dJ99NNfwGyHynxCmjhYsrcwqbY60= github.com/gopherjs/gopherjs v1.20.1/go.mod h1:h+FTmmLgbXMmmtuZFp9bUqXciN429Wx0sJEJuMnpyfM= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= @@ -348,8 +346,8 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd h1:dSIuz2mpJAPQfhHYtG57D0qwSkgC/vQ69gHfeyQ4kxA= -github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0= +github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160 h1:PV6ipOJN0JIek4dqPqsTtenQmjI1SZntCUgPzhfk9gM= +github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160/go.mod h1:yRuPDnHgGmsbvopF0amMqXxr4n32GynzX5GTwFYDHaw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -372,8 +370,8 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= -github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80Mcn3nemvilMPvpWqxrNfK2ZnNNs= -github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI= +github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb h1:6vkM8gO+zFV2m21QzGYyUSq5TP0VQgP2Xz3UQyCN2kI= +github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb/go.mod h1:H1+whwD0Qe3YOunlXIWhh3rlvzW5cZfkMDYGQPg+KAM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -384,8 +382,8 @@ github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 h1:JcltaO1HXM5S2K github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7/go.mod h1:MEkhEPFwP3yudWO0lj6vfYpLIB+3eIcuIW+e0AZzUQk= github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg= github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -425,8 +423,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lpar/date v1.0.0 h1:bq/zVqFTUmsxvd/CylidY4Udqpr9BOFrParoP6p0x/I= github.com/lpar/date v1.0.0/go.mod h1:KjYe0dDyMQTgpqcUz4LEIeM5VZwhggjVx/V2dtc8NSo= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= +github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= @@ -439,8 +437,9 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk= +github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -459,6 +458,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= @@ -471,26 +472,25 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO/S8+rvgK0= -github.com/oracle/oci-go-sdk/v65 v65.104.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY= -github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= -github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= +github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk= +github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs= +github.com/panjf2000/ants/v2 v2.11.5 h1:a7LMnMEeux/ebqTux140tRiaqcFTV0q2bEHF03nl6Rg= +github.com/panjf2000/ants/v2 v2.11.5/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 h1:XeOYlK9W1uCmhjJSsY78Mcuh7MVkNjTzmHx1yBzizSU= github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14/go.mod h1:jVblp62SafmidSkvWrXyxAme3gaTfEtWwRPGz5cpvHg= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= @@ -510,24 +510,26 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8= github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU= -github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs= -github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM= -github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM= -github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc= -github.com/rclone/rclone v1.73.5 h1:r8a9JHYIWUqk7hNRJuMJ3cROkKfB2zmfkADg8ZLYh6I= -github.com/rclone/rclone v1.73.5/go.mod h1:WVv8gvA/lEl/Y37e8I8yosm7ZY+Szq7ujXbJS8Ol63o= +github.com/rclone/Proton-API-Bridge v1.0.3 h1:Bs7RC4xCFSN0BPIYVda/BNxp0qo3NV0gB2VZqx2KIew= +github.com/rclone/Proton-API-Bridge v1.0.3/go.mod h1:26RAest751Ofk+F/d8xtl4UyWXrZvMQwn39U8rm/WKM= +github.com/rclone/go-proton-api v1.0.2 h1:cJtJUab0MGJ3C6q5kiEJs3pbyhSLnOKMyYOQehA0PBc= +github.com/rclone/go-proton-api v1.0.2/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc= +github.com/rclone/rclone v1.74.3 h1:a2wln7pvEa0tS1WIZJKulEkVjxgC1DkCoyxYydkdiSY= +github.com/rclone/rclone v1.74.3/go.mod h1:t5Mh86PO49DD7xlPt0trnK/aNf2Z3M0uip4l1Jqwiv8= github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo= github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4= -github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= +github.com/rfjakob/eme v1.2.0 h1:8dAHL+WVAw06+7DkRKnRiFp1JL3QjcJEZFqDnndUaSI= +github.com/rfjakob/eme v1.2.0/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -539,12 +541,13 @@ github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= -github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= @@ -568,6 +571,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= @@ -575,12 +579,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 h1:rrGZv6xYk37hx0tW2sYfgbO0PqStbHqz6Bq6oc9Hurg= -github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo= -github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= -github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= -github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c h1:dtcOwRimeiBFrlutmF6K94l0rxYFARNFMA+lSQ41C+M= +github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo= +github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= @@ -595,6 +600,7 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yunify/qingstor-sdk-go/v3 v3.2.0 h1:9sB2WZMgjwSUNZhrgvaNGazVltoFUUfuS9f0uCWtTr8= github.com/yunify/qingstor-sdk-go/v3 v3.2.0/go.mod h1:KciFNuMu6F4WLk9nGwwK69sCGKLCdd9f97ac/wfumS4= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -611,12 +617,12 @@ github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= -go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= -go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -625,10 +631,10 @@ go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSf go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM= @@ -665,23 +671,36 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= -golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -690,22 +709,30 @@ golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= -google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI= +google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= @@ -725,6 +752,7 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -780,8 +808,8 @@ sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/ sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= -storj.io/common v0.0.0-20251107171817-6221ae45072c h1:UDXSrdeLJe3QFouavSW10fYdpclK0YNu3KvQHzqq2+k= -storj.io/common v0.0.0-20251107171817-6221ae45072c/go.mod h1:XNX7uykja6aco92y2y8RuqaXIDRPpt1YA2OQDKlKEUk= +storj.io/common v0.0.0-20260225132117-99155641c30a h1:7gSBQY3vhQMIqi3vfaEXR7mreRjcBLfVsYY0rHIN7P0= +storj.io/common v0.0.0-20260225132117-99155641c30a/go.mod h1:kyxwKwlfH4paBZCZt/szvRB770ieAIJF73iDy2DpEHw= storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 h1:8OE12DvUnB9lfZcHe7IDGsuhjrY9GBAr964PVHmhsro= storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg= storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 h1:5MZ0CyMbG6Pi0rRzUWVG6dvpXjbBYEX2oyXuj+tT+sk= @@ -790,5 +818,5 @@ storj.io/infectious v0.0.2 h1:rGIdDC/6gNYAStsxsZU79D/MqFjNyJc1tsyyj9sTl7Q= storj.io/infectious v0.0.2/go.mod h1:QEjKKww28Sjl1x8iDsjBpOM4r1Yp8RsowNcItsZJ1Vs= storj.io/picobuf v0.0.4 h1:qswHDla+YZ2TovGtMnU4astjvrADSIz84FXRn0qgP6o= storj.io/picobuf v0.0.4/go.mod h1:hSMxmZc58MS/2qSLy1I0idovlO7+6K47wIGUyRZa6mg= -storj.io/uplink v1.13.1 h1:C8RdW/upALoCyuF16Lod9XGCXEdbJAS+ABQy9JO/0pA= -storj.io/uplink v1.13.1/go.mod h1:x0MQr4UfFsQBwgVWZAtEsLpuwAn6dg7G0Mpne1r516E= +storj.io/uplink v1.14.0 h1:J1yXlt0aRr6kgLTHWXOWosNCFVfbamlcyd+CSxyIczo= +storj.io/uplink v1.14.0/go.mod h1:2ysmjzd/1Xtz4VKoErNcSqBQz3UC9WKTVuLMV1cNu6E= diff --git a/harnesses/antigravity/Dockerfile b/harnesses/antigravity/Dockerfile index bf8c1b4f3..6471cbd1d 100644 --- a/harnesses/antigravity/Dockerfile +++ b/harnesses/antigravity/Dockerfile @@ -16,17 +16,28 @@ ARG BASE_IMAGE FROM ${BASE_IMAGE} +ARG TARGETARCH + USER root RUN mkdir -p /home/scion/.gemini/antigravity-cli \ && mkdir -p /home/scion/.agents \ && chown -R scion:scion /home/scion/.gemini /home/scion/.agents -# Install Antigravity CLI -RUN curl -fsSL -o cli.tar.gz https://storage.googleapis.com/antigravity-public/antigravity-cli/1.0.0-5288553236791296/linux-x64/cli_linux_x64.tar.gz \ - && tar -xzf cli.tar.gz \ - && mv antigravity /usr/local/bin/agy \ +# Fetch the latest CLI via the auto-updater manifest for the current platform. +# This trades build reproducibility for staying current, but verifies the +# download integrity via SHA-512 checksum from the manifest. +RUN PLATFORM="linux_${TARGETARCH:-amd64}" \ + && MANIFEST=$(curl -fsSL "https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/${PLATFORM}.json") \ + && URL=$(printf '%s\n' "$MANIFEST" | python3 -c "import sys, json; print(json.load(sys.stdin)['url'])") \ + && SHA512=$(printf '%s\n' "$MANIFEST" | python3 -c "import sys, json; print(json.load(sys.stdin)['sha512'])") \ + && curl -fsSL -o /tmp/cli.tar.gz "$URL" \ + && printf '%s /tmp/cli.tar.gz\n' "$SHA512" | sha512sum -c - \ + && tar -xzf /tmp/cli.tar.gz -C /tmp antigravity \ + && mv /tmp/antigravity /usr/local/bin/agy \ && chmod +x /usr/local/bin/agy \ - && rm cli.tar.gz + && rm /tmp/cli.tar.gz + +RUN printf 'if [ -z "$DBUS_SESSION_BUS_ADDRESS" ]; then\n eval $(dbus-launch --sh-syntax)\n export DBUS_SESSION_BUS_ADDRESS\nfi\nif ! pgrep gnome-keyring-daemon >/dev/null; then\n eval $(echo "test" | gnome-keyring-daemon --unlock 2>/dev/null)\n gnome-keyring-daemon --start --components=secrets,pkcs11,ssh >/dev/null 2>&1\nfi\n' >> /etc/bash.bashrc CMD ["agy"] diff --git a/harnesses/antigravity/README.md b/harnesses/antigravity/README.md index 00fab9230..73f750c94 100644 --- a/harnesses/antigravity/README.md +++ b/harnesses/antigravity/README.md @@ -1,8 +1,8 @@ # Antigravity Harness Bundle Scion harness configuration for -[Antigravity](https://github.com/ptone/scion-antigravity), a Gemini-based -coding agent CLI using OAuth via gnome-keyring. +[Antigravity CLI](https://antigravity.google/product/antigravity-cli), a +Gemini-based coding agent CLI using OAuth via gnome-keyring. ## Install diff --git a/harnesses/antigravity/capture_auth.py b/harnesses/antigravity/capture_auth.py new file mode 100644 index 000000000..dc131a02e --- /dev/null +++ b/harnesses/antigravity/capture_auth.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Antigravity capture-auth script. + +Captures the Antigravity OAuth token file and stores it as a project-scoped +secret via `sciontool secret set`. Designed to run after the user authenticates +interactively inside a no-auth agent container. + +AGY stores its OAuth token as a JSON file at: + ~/.gemini/antigravity-cli/antigravity-oauth-token + +This script reads the file, validates it contains a refresh_token, and stores +it as the AGY_TOKEN secret. + +Exit codes: + 0 = credential captured + 1 = error + 2 = no credentials found (not an error, but nothing was stored) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from typing import Any + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_CREDS = 2 + +HARNESS_BUNDLE = os.path.join( + os.environ.get("HOME") or os.path.expanduser("~"), + ".scion", "harness", +) + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_config(bundle: str) -> list[dict[str, Any]]: + config_path = os.path.join(bundle, "inputs", "capture-auth-config.json") + if not os.path.isfile(config_path): + return [] + with open(config_path, "r", encoding="utf-8") as f: + try: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return [] + creds = data.get("credentials") + if not isinstance(creds, list): + return [] + return creds + + +def _validate_token_file(path: str) -> bool: + try: + with open(path, "r", encoding="utf-8") as f: + obj = json.load(f) + except (json.JSONDecodeError, OSError) as exc: + print(f"capture-auth: token file is not valid JSON: {exc}", file=sys.stderr) + return False + has_refresh = ( + "refresh_token" in obj + or (isinstance(obj.get("token"), dict) and "refresh_token" in obj["token"]) + ) + if not isinstance(obj, dict) or not has_refresh: + print("capture-auth: token file missing refresh_token field", file=sys.stderr) + return False + return True + + +def _capture_one(entry: dict[str, Any], force: bool) -> tuple[bool, str | None]: + key = entry.get("key", "") or entry.get("name", "") + source = _expand(entry.get("source", "")) + secret_type = entry.get("type", "file") + target = entry.get("target", "") + + if not key or not source: + return False, "invalid entry: missing key or source" + + if not os.path.isfile(source): + return False, None + + cmd = [ + "sciontool", "secret", "set", key, f"@{source}", + "--type", secret_type, + "--target", target, + ] + if force: + cmd.append("--force") + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + except FileNotFoundError: + return False, "sciontool not found in PATH" + except subprocess.TimeoutExpired: + return False, f"sciontool timed out for key {key}" + + if result.returncode != 0: + stderr = result.stderr.strip() + if "already exists" in stderr: + return True, None + return False, f"sciontool failed for {key}: {stderr}" + + return True, None + + +def _setup_dbus() -> bool: + """Set DBUS_SESSION_BUS_ADDRESS from saved env file if not already set.""" + if os.environ.get("DBUS_SESSION_BUS_ADDRESS"): + return True + home = os.environ.get("HOME") or os.path.expanduser("~") + dbus_env_file = os.path.join(home, ".scion", "harness", ".dbus-env") + try: + with open(dbus_env_file, "r") as f: + for line in f: + line = line.strip() + if line.startswith("DBUS_SESSION_BUS_ADDRESS="): + addr = line[len("DBUS_SESSION_BUS_ADDRESS="):] + os.environ["DBUS_SESSION_BUS_ADDRESS"] = addr + return True + except OSError: + pass + return False + + +def _extract_from_keyring() -> str | None: + """Extract the AGY OAuth token from gnome-keyring via secret-tool.""" + if not _setup_dbus(): + print("capture-auth: DBUS session not available for keyring access", file=sys.stderr) + return None + try: + result = subprocess.run( + ["secret-tool", "lookup", "service", "gemini", "username", "antigravity"], + capture_output=True, text=True, timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + print(f"capture-auth: secret-tool error: {exc}", file=sys.stderr) + return None + if result.returncode != 0 or not result.stdout.strip(): + return None + return result.stdout.strip() + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Capture Antigravity auth token and store as project secret" + ) + parser.add_argument("--force", action="store_true", help="Overwrite existing secrets") + parser.add_argument("--bundle", default=HARNESS_BUNDLE) + args = parser.parse_args() + + entries = _load_config(args.bundle) + + if not entries: + home = os.environ.get("HOME") or os.path.expanduser("~") + token_path = os.path.join(home, ".gemini", "antigravity-cli", "antigravity-oauth-token") + entries = [{ + "key": "AGY_TOKEN", + "source": token_path, + "type": "file", + "target": "~/.gemini/antigravity-cli/antigravity-oauth-token", + }] + + seen_keys: set[str] = set() + unique_entries = [] + for e in entries: + k = e.get("key", "") or e.get("name", "") + if k not in seen_keys: + seen_keys.add(k) + unique_entries.append(e) + entries = unique_entries + + captured = 0 + errors = 0 + + for entry in entries: + key = entry.get("key", "") or entry.get("name", "") + source = entry.get("source", "") + expanded = _expand(source) if source else "" + + if not expanded or not os.path.isfile(expanded): + print(f"capture-auth: {key}: source not found ({source})") + continue + + if not _validate_token_file(expanded): + errors += 1 + continue + + ok, err = _capture_one(entry, args.force) + if err: + print(f"capture-auth: {key}: {err}", file=sys.stderr) + errors += 1 + elif ok: + print(f"capture-auth: {key}: captured from {source}") + captured += 1 + + # Keyring fallback: AGY may store tokens only in gnome-keyring, not to file + if captured == 0 and errors == 0: + print("capture-auth: file not found, trying gnome-keyring fallback...") + token = _extract_from_keyring() + if token: + target = "~/.gemini/antigravity-cli/antigravity-oauth-token" + fd, tmp_path = tempfile.mkstemp(prefix="agy_token_", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + f.write(token) + cmd = ["sciontool", "secret", "set", "AGY_TOKEN", f"@{tmp_path}", + "--type", "file", "--target", target] + if args.force: + cmd.append("--force") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + print(f"capture-auth: AGY_TOKEN: captured from gnome-keyring") + captured += 1 + else: + print(f"capture-auth: keyring fallback failed: {result.stderr.strip()}", file=sys.stderr) + errors += 1 + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + if errors > 0 and captured == 0: + return EXIT_ERROR + + if captured == 0: + print("capture-auth: no credentials found to capture") + print(" Make sure you have authenticated with: agy") + return EXIT_NO_CREDS + + print(f"capture-auth: {captured} credential(s) captured successfully") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harnesses/antigravity/config.yaml b/harnesses/antigravity/config.yaml index 1b34153ff..a076187e8 100644 --- a/harnesses/antigravity/config.yaml +++ b/harnesses/antigravity/config.yaml @@ -50,22 +50,37 @@ capabilities: system_prompt: { support: "partial", reason: "System prompt is downgraded to GEMINI.md preamble" } agent_instructions: { support: "yes" } auth: - api_key: { support: "no", reason: "AGY uses OAuth via gnome-keyring, not API keys" } - auth_file: { support: "no", reason: "AGY uses OAuth via gnome-keyring" } - oauth_token: { support: "yes", reason: "Via gnome-keyring population from AGY_KEYRING_TOKEN secret" } - vertex_ai: { support: "yes", reason: "Enterprise/GCP mode via AGY_KEYRING_TOKEN + GOOGLE_CLOUD_PROJECT" } + api_key: { support: "no", reason: "AGY uses OAuth, not API keys" } + auth_file: { support: "yes", reason: "Via AGY_TOKEN file secret at ~/.gemini/antigravity-cli/antigravity-oauth-token" } + oauth_token: { support: "no", reason: "AGY uses file-based OAuth token, not raw token injection" } + vertex_ai: { support: "yes", reason: "Enterprise/GCP mode via AGY_TOKEN + GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION" } + +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Run: agy + Complete the interactive login flow. + Then run: python3 /home/scion/.scion/harness/capture_auth.py auth: + default_type: oauth-token types: oauth-token: - required_env: - - any_of: ["AGY_KEYRING_TOKEN"] + required_files: + - name: AGY_TOKEN + type: file + description: "Antigravity OAuth token JSON file" + target_suffix: "/.gemini/antigravity-cli/antigravity-oauth-token" vertex-ai: - # Enterprise/GCP mode: the keyring token is still required (provision.py - # validates AGY_KEYRING_TOKEN), plus the GCP project for Vertex routing. required_env: - - any_of: ["AGY_KEYRING_TOKEN"] - any_of: ["GOOGLE_CLOUD_PROJECT"] + - any_of: ["GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_REGION"] + required_files: + - name: AGY_TOKEN + type: file + description: "Antigravity OAuth token JSON file" + target_suffix: "/.gemini/antigravity-cli/antigravity-oauth-token" autodetect: env: - AGY_KEYRING_TOKEN: oauth-token + AGY_TOKEN: oauth-token diff --git a/harnesses/antigravity/provision.py b/harnesses/antigravity/provision.py index 0b307f956..c14e50953 100644 --- a/harnesses/antigravity/provision.py +++ b/harnesses/antigravity/provision.py @@ -59,10 +59,8 @@ def _write_json(path: str, payload: Any) -> None: def _present_env_keys(candidates: dict[str, Any]) -> set[str]: raw = candidates.get("env_vars") or [] keys = {str(k) for k in raw if isinstance(k, str)} - # Also detect AGY_KEYRING_TOKEN from environment (may be injected - # as a plain env var rather than staged through the secret pipeline). - if os.environ.get("AGY_KEYRING_TOKEN"): - keys.add("AGY_KEYRING_TOKEN") + if os.environ.get("AGY_TOKEN"): + keys.add("AGY_TOKEN") return keys @@ -105,9 +103,18 @@ def _parse_env_output(output: str, env: dict[str, str]) -> None: def _select_auth_method( - explicit: str, env_keys: set[str] + explicit: str, env_keys: set[str], secret_files: dict[str, str], + home: str, ) -> tuple[str, str]: - has_keyring = "AGY_KEYRING_TOKEN" in env_keys + has_token = "AGY_TOKEN" in secret_files or bool(_read_secret(secret_files, "AGY_TOKEN")) + if not has_token: + # AGY_TOKEN may be a file-type secret bind-mounted directly to its + # target path rather than staged to the env secret directory. + has_token = os.path.isfile(os.path.join( + home, ".gemini", "antigravity-cli", "antigravity-oauth-token" + )) + has_gcp_project = any(k in env_keys for k in ("GOOGLE_CLOUD_PROJECT",)) + has_gcp_location = any(k in env_keys for k in ("GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_REGION")) if explicit: if explicit not in VALID_AUTH_TYPES: @@ -116,28 +123,24 @@ def _select_auth_method( f"valid types are: {', '.join(VALID_AUTH_TYPES)}" ) if explicit == "vertex-ai": - if has_keyring: - return "vertex-ai", "AGY_KEYRING_TOKEN" - raise ValueError( - "antigravity: auth type 'vertex-ai' selected but " - "AGY_KEYRING_TOKEN secret not found" - ) + if not has_token: + raise ValueError("antigravity: auth type 'vertex-ai' selected but AGY_TOKEN secret not found") + if not has_gcp_project: + raise ValueError("antigravity: auth type 'vertex-ai' selected but GOOGLE_CLOUD_PROJECT not found") + if not has_gcp_location: + raise ValueError("antigravity: auth type 'vertex-ai' selected but GOOGLE_CLOUD_LOCATION/REGION not found") + return "vertex-ai", "AGY_TOKEN" if explicit == "oauth-token": - if has_keyring: - return "oauth-token", "AGY_KEYRING_TOKEN" - raise ValueError( - "antigravity: auth type 'oauth-token' selected but " - "AGY_KEYRING_TOKEN secret not found" - ) + if not has_token: + raise ValueError("antigravity: auth type 'oauth-token' selected but AGY_TOKEN secret not found") + return "oauth-token", "AGY_TOKEN" if explicit == "none": return "none", "" - if has_keyring: - # AGY_USE_GCP=true promotes to vertex-ai (enterprise) mode - use_gcp = os.environ.get("AGY_USE_GCP", "").lower() in ("true", "1", "yes") - if use_gcp: - return "vertex-ai", "AGY_KEYRING_TOKEN" - return "oauth-token", "AGY_KEYRING_TOKEN" + if has_token: + if has_gcp_project and has_gcp_location: + return "vertex-ai", "AGY_TOKEN" + return "oauth-token", "AGY_TOKEN" return "none", "" @@ -194,9 +197,7 @@ def _tool_hook(event: str) -> list[dict[str, Any]]: ) -def _generate_wrapper_script( - home: str, has_token: bool, is_enterprise: bool, -) -> None: +def _generate_wrapper_script(home: str, has_token: bool, is_enterprise: bool) -> None: """Generate agy-wrapper.sh that inits keyring and execs AGY. The keyring daemons must run in the same process tree as AGY so they @@ -204,7 +205,7 @@ def _generate_wrapper_script( dies when the provisioner exits, so we bootstrap inline here. Token injection always includes an env-var fallback because the - provisioner runs before scion-env is sourced — AGY_KEYRING_TOKEN may + provisioner runs before scion-env is sourced — AGY_TOKEN may only be available in the child process environment, not during provisioning. GCP/enterprise settings are also patched here at runtime for the same @@ -212,7 +213,10 @@ def _generate_wrapper_script( are only available in the child environment. """ secret_path = os.path.join( - home, ".scion", "harness", "secrets", "AGY_KEYRING_TOKEN" + home, ".scion", "harness", "secrets", "AGY_TOKEN" + ) + oauth_token_path = os.path.join( + home, ".gemini", "antigravity-cli", "antigravity-oauth-token" ) settings_path = os.path.join( home, ".gemini", "antigravity-cli", "settings.json" @@ -250,16 +254,26 @@ def _generate_wrapper_script( echo "agy-wrapper: keyring initialized (DBUS=$DBUS_SESSION_BUS_ADDRESS)" >&2 -# Inject OAuth token into keyring (secret file first, env var fallback) +# Save DBUS session address so other processes (e.g. capture_auth.py) can use the keyring +echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" > ~/.scion/harness/.dbus-env + +# Inject OAuth token into keyring (staging file, target path, env var fallback) if [ -f "{secret_path}" ]; then secret-tool store \\ --label="Password for antigravity on gemini" \\ service gemini username antigravity \\ < "{secret_path}" 2>/dev/null \\ - && echo "agy-wrapper: token injected into keyring (from file)" >&2 \\ + && echo "agy-wrapper: token injected into keyring (from staging file)" >&2 \\ + || echo "agy-wrapper: WARNING: failed to inject token" >&2 +elif [ -f "{oauth_token_path}" ]; then + secret-tool store \\ + --label="Password for antigravity on gemini" \\ + service gemini username antigravity \\ + < "{oauth_token_path}" 2>/dev/null \\ + && echo "agy-wrapper: token injected into keyring (from target path)" >&2 \\ || echo "agy-wrapper: WARNING: failed to inject token" >&2 -elif [ -n "${{AGY_KEYRING_TOKEN:-}}" ]; then - printf '%s' "$AGY_KEYRING_TOKEN" | secret-tool store \\ +elif [ -n "${{AGY_TOKEN:-}}" ]; then + printf '%s' "$AGY_TOKEN" | secret-tool store \\ --label="Password for antigravity on gemini" \\ service gemini username antigravity 2>/dev/null \\ && echo "agy-wrapper: token injected into keyring (from env)" >&2 \\ @@ -276,11 +290,13 @@ def _generate_wrapper_script( _use_gcp=true elif [ "${{AGY_USE_GCP:-}}" = "true" ] || [ "${{AGY_USE_GCP:-}}" = "1" ] || [ "${{AGY_USE_GCP:-}}" = "yes" ]; then _use_gcp=true +elif [ -n "${{GOOGLE_CLOUD_PROJECT:-}}" ]; then + _use_gcp=true fi if [ "$_use_gcp" = "true" ]; then _gcp_project="${{GOOGLE_CLOUD_PROJECT:-}}" - _gcp_location="${{GOOGLE_CLOUD_LOCATION:-global}}" + _gcp_location="${{GOOGLE_CLOUD_LOCATION:-${{GOOGLE_CLOUD_REGION:-global}}}}" if [ -n "$_gcp_project" ]; then python3 -c " @@ -562,7 +578,7 @@ def _provision(manifest: dict[str, Any]) -> int: env_key = "" else: try: - method, env_key = _select_auth_method(explicit, env_keys) + method, env_key = _select_auth_method(explicit, env_keys, secret_files, home) except ValueError as exc: print(str(exc), file=sys.stderr) return EXIT_ERROR @@ -588,26 +604,38 @@ def _provision(manifest: dict[str, Any]) -> int: # Validate token if an auth method requiring it was selected has_token = False if method in ("oauth-token", "vertex-ai"): - token_raw = _read_secret(secret_files, "AGY_KEYRING_TOKEN") + token_raw = _read_secret(secret_files, "AGY_TOKEN") if not token_raw: - print( - "antigravity provision: AGY_KEYRING_TOKEN secret is empty", - file=sys.stderr, + # AGY_TOKEN may be a file-type secret bind-mounted directly to its + # target path rather than staged to the env secret directory. + oauth_token_path = os.path.join( + home, ".gemini", "antigravity-cli", "antigravity-oauth-token" ) + try: + with open(oauth_token_path, "r", encoding="utf-8") as f: + token_raw = f.read().rstrip("\r\n") + if token_raw: + print( + f"antigravity provision: read AGY_TOKEN from " + f"bind-mounted path {oauth_token_path}", + file=sys.stderr, + ) + except OSError: + pass + if not token_raw: + print("antigravity provision: AGY_TOKEN secret is empty", file=sys.stderr) return EXIT_ERROR try: token_obj = json.loads(token_raw) except json.JSONDecodeError as exc: - print( - f"antigravity provision: AGY_KEYRING_TOKEN is not valid JSON: {exc}", - file=sys.stderr, - ) + print(f"antigravity provision: AGY_TOKEN is not valid JSON: {exc}", file=sys.stderr) return EXIT_ERROR - if not isinstance(token_obj, dict) or "refresh_token" not in token_obj: - print( - "antigravity provision: AGY_KEYRING_TOKEN must contain refresh_token", - file=sys.stderr, - ) + has_refresh = ( + "refresh_token" in token_obj + or (isinstance(token_obj.get("token"), dict) and "refresh_token" in token_obj["token"]) + ) + if not isinstance(token_obj, dict) or not has_refresh: + print("antigravity provision: AGY_TOKEN must contain refresh_token", file=sys.stderr) return EXIT_ERROR has_token = True diff --git a/image-build/scripts/builders/local-docker.sh b/image-build/scripts/builders/local-docker.sh index ff10e5fdb..bdc6c9e98 100755 --- a/image-build/scripts/builders/local-docker.sh +++ b/image-build/scripts/builders/local-docker.sh @@ -34,17 +34,27 @@ builder_check() { } builder_prepare() { + if [[ "${PUSH:-false}" != "true" ]]; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo "[dry-run] export BUILDX_BUILDER=default" + return 0 + fi + echo "Using default docker builder for local build..." + export BUILDX_BUILDER="default" + return 0 + fi + if [[ "${DRY_RUN:-false}" == "true" ]]; then - echo "[dry-run] docker buildx create --name ${BUILDX_INSTANCE} --use # if missing" + echo "[dry-run] docker buildx create --name ${BUILDX_INSTANCE} # if missing" + echo "[dry-run] export BUILDX_BUILDER=${BUILDX_INSTANCE}" echo "[dry-run] docker buildx inspect --bootstrap" return 0 fi if ! docker buildx inspect "${BUILDX_INSTANCE}" >/dev/null 2>&1; then echo "Creating buildx builder '${BUILDX_INSTANCE}'..." - docker buildx create --name "${BUILDX_INSTANCE}" --use - else - docker buildx use "${BUILDX_INSTANCE}" + docker buildx create --name "${BUILDX_INSTANCE}" fi + export BUILDX_BUILDER="${BUILDX_INSTANCE}" docker buildx inspect --bootstrap >/dev/null } diff --git a/pkg/agent/github_uri.go b/pkg/agent/github_uri.go index 2acb39b67..6bf407965 100644 --- a/pkg/agent/github_uri.go +++ b/pkg/agent/github_uri.go @@ -35,13 +35,31 @@ type GitHubSkillRef struct { // ParseGitHubSkillURI parses a gh:// shorthand or full GitHub URL // into a GitHubSkillRef. func ParseGitHubSkillURI(uri string) (*GitHubSkillRef, error) { - if strings.HasPrefix(uri, "gh://") { - return parseGHShorthand(uri) + lower := strings.ToLower(uri) + + var normalized string + switch { + case strings.HasPrefix(lower, "gh://"): + normalized = "gh://" + uri[len("gh://"):] + case strings.HasPrefix(lower, "https://github.com/"): + normalized = "https://github.com/" + uri[len("https://github.com/"):] + case strings.HasPrefix(lower, "http://github.com/"): + normalized = "http://github.com/" + uri[len("http://github.com/"):] + default: + return nil, fmt.Errorf("not a GitHub skill URI: %q", uri) + } + + var ref *GitHubSkillRef + var err error + if strings.HasPrefix(normalized, "gh://") { + ref, err = parseGHShorthand(normalized) + } else { + ref, err = parseGitHubFullURL(normalized) } - if strings.HasPrefix(uri, "https://github.com/") || strings.HasPrefix(uri, "http://github.com/") { - return parseGitHubFullURL(uri) + if ref != nil { + ref.Raw = uri } - return nil, fmt.Errorf("not a GitHub skill URI: %q", uri) + return ref, err } func parseGHShorthand(uri string) (*GitHubSkillRef, error) { diff --git a/pkg/agent/github_uri_test.go b/pkg/agent/github_uri_test.go index 3c3864927..3980bfba9 100644 --- a/pkg/agent/github_uri_test.go +++ b/pkg/agent/github_uri_test.go @@ -94,6 +94,28 @@ func TestParseGHShorthand(t *testing.T) { uri: "skill://my-skill", wantError: true, }, + { + name: "uppercase scheme GH://", + uri: "GH://owner/repo/skill", + want: &GitHubSkillRef{ + Owner: "owner", + Repo: "repo", + SkillName: "skill", + Ref: "", + SkillPath: "skills/skill", + }, + }, + { + name: "mixed-case scheme Gh:// with ref", + uri: "Gh://owner/repo/skill@main", + want: &GitHubSkillRef{ + Owner: "owner", + Repo: "repo", + SkillName: "skill", + Ref: "main", + SkillPath: "skills/skill", + }, + }, } for _, tt := range tests { @@ -180,6 +202,28 @@ func TestParseGitHubFullURL(t *testing.T) { uri: "https://github.com/owner/repo/blob/main/file.go", wantError: true, }, + { + name: "uppercase HTTPS scheme and host", + uri: "HTTPS://GITHUB.COM/owner/repo/tree/main/skills/skill", + want: &GitHubSkillRef{ + Owner: "owner", + Repo: "repo", + SkillName: "skill", + Ref: "main", + SkillPath: "skills/skill", + }, + }, + { + name: "mixed-case Http scheme and host", + uri: "Http://GitHub.Com/owner/repo/tree/main/skills/skill", + want: &GitHubSkillRef{ + Owner: "owner", + Repo: "repo", + SkillName: "skill", + Ref: "main", + SkillPath: "skills/skill", + }, + }, } for _, tt := range tests { diff --git a/pkg/agent/provision.go b/pkg/agent/provision.go index aef13921a..1667b5d75 100644 --- a/pkg/agent/provision.go +++ b/pkg/agent/provision.go @@ -738,6 +738,7 @@ func ProvisionAgent(ctx context.Context, agentName string, templateName string, TemplatePaths: templatePaths, ProfileName: profileName, Settings: settings, + ConfigDirPath: api.HarnessConfigPathFromContext(ctx), }) if err != nil { return "", "", nil, fmt.Errorf("failed to resolve harness for %q: %w", harnessConfigName, err) diff --git a/pkg/agent/routing_skill_resolver.go b/pkg/agent/routing_skill_resolver.go index 326efe06e..0eb84a202 100644 --- a/pkg/agent/routing_skill_resolver.go +++ b/pkg/agent/routing_skill_resolver.go @@ -103,20 +103,21 @@ func (r *RoutingSkillResolver) Resolve(ctx context.Context, refs []api.SkillRefe // detectScheme extracts the routing scheme from a skill URI. func detectScheme(uri string) string { - if strings.HasPrefix(uri, "gh://") { + lower := strings.ToLower(uri) + if strings.HasPrefix(lower, "gh://") { return "gh" } - if strings.HasPrefix(uri, "gcp-skill://") { + if strings.HasPrefix(lower, "gcp-skill://") { return "gcp-skill" } - if strings.HasPrefix(uri, "https://github.com/") || strings.HasPrefix(uri, "http://github.com/") { + if strings.HasPrefix(lower, "https://github.com/") || strings.HasPrefix(lower, "http://github.com/") { return "gh" } - if strings.HasPrefix(uri, "skill://") || !strings.Contains(uri, "://") { + if strings.HasPrefix(lower, "skill://") || !strings.Contains(lower, "://") { return "skill" } - if idx := strings.Index(uri, "://"); idx > 0 { - return uri[:idx] + if idx := strings.Index(lower, "://"); idx > 0 { + return lower[:idx] } return "" } diff --git a/pkg/agent/run.go b/pkg/agent/run.go index ced5bdbc8..f72aaf527 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -361,6 +361,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A TemplatePaths: resolveTemplatePaths, ProfileName: profileName, Settings: settings, + ConfigDirPath: opts.HarnessConfigPath, }) if err != nil { util.Debugf("harness.Resolve fell back to New(%q): %v", harnessName, err) diff --git a/pkg/api/skill_uri.go b/pkg/api/skill_uri.go index 18fdf8d77..9d67438ca 100644 --- a/pkg/api/skill_uri.go +++ b/pkg/api/skill_uri.go @@ -229,7 +229,7 @@ func parseScopedPath(raw string, uri *SkillURI, segments []string, version strin // This function is a lightweight utility for non-routing scheme checks. func SkillURIScheme(uri string) string { if idx := strings.Index(uri, "://"); idx > 0 { - return uri[:idx] + return strings.ToLower(uri[:idx]) } return "skill" } diff --git a/pkg/config/harness_config.go b/pkg/config/harness_config.go index 2e2d550d9..6ea00a9de 100644 --- a/pkg/config/harness_config.go +++ b/pkg/config/harness_config.go @@ -72,8 +72,16 @@ func LoadHarnessConfigDir(dirPath string) (*HarnessConfigDir, error) { return nil, fmt.Errorf("failed to parse config.yaml: %w", err) } + name := filepath.Base(absPath) + if entry.Name != "" { + if entry.Name == "." || entry.Name == ".." || strings.ContainsAny(entry.Name, "/\\") { + return nil, fmt.Errorf("invalid name in config.yaml: %q contains path components or separators", entry.Name) + } + name = entry.Name + } + return &HarnessConfigDir{ - Name: filepath.Base(absPath), + Name: name, Path: absPath, Config: entry, }, nil diff --git a/pkg/config/harness_config_test.go b/pkg/config/harness_config_test.go index 4db66725e..f9ce1dbd5 100644 --- a/pkg/config/harness_config_test.go +++ b/pkg/config/harness_config_test.go @@ -117,6 +117,24 @@ func TestLoadHarnessConfigDir_InvalidUnknownField(t *testing.T) { } } +func TestLoadHarnessConfigDir_RejectsPathTraversalName(t *testing.T) { + for _, bad := range []string{"../evil", "sub/dir", "etc/passwd", "..", "."} { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "legit") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + yaml := "harness: claude\nname: " + bad + "\n" + if err := os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + _, err := LoadHarnessConfigDir(configDir) + if err == nil { + t.Errorf("expected error for name %q, got nil", bad) + } + } +} + func TestLoadHarnessConfigDir_NotFound(t *testing.T) { _, err := LoadHarnessConfigDir("/nonexistent/path") if err == nil { diff --git a/pkg/config/schemas/settings-v1.schema.json b/pkg/config/schemas/settings-v1.schema.json index af7e542ee..5f38f18b3 100644 --- a/pkg/config/schemas/settings-v1.schema.json +++ b/pkg/config/schemas/settings-v1.schema.json @@ -233,6 +233,11 @@ "type": "object", "required": ["harness"], "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]*$", + "description": "Override name for this harness-config, used instead of the directory name during import/install." + }, "harness": { "type": "string", "minLength": 1, diff --git a/pkg/config/settings_v1.go b/pkg/config/settings_v1.go index 9f6f3bdf3..b6c6e4e32 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -696,6 +696,7 @@ type V1RuntimeConfig struct { // HarnessConfigEntry defines a harness configuration entry in versioned settings. // The Harness field is required and specifies the harness type this config applies to. type HarnessConfigEntry struct { + Name string `json:"name,omitempty" yaml:"name,omitempty" koanf:"name"` Harness string `json:"harness" yaml:"harness" koanf:"harness"` Image string `json:"image,omitempty" yaml:"image,omitempty" koanf:"image"` User string `json:"user,omitempty" yaml:"user,omitempty" koanf:"user"` diff --git a/pkg/ent/entc/client.go b/pkg/ent/entc/client.go index 4b91c07a6..bab93e231 100644 --- a/pkg/ent/entc/client.go +++ b/pkg/ent/entc/client.go @@ -28,7 +28,6 @@ import ( "github.com/jackc/pgx/v5/stdlib" "github.com/GoogleCloudPlatform/scion/pkg/ent" - "github.com/GoogleCloudPlatform/scion/pkg/ent/migrate" ) // PoolConfig holds connection pool settings applied to the underlying @@ -171,7 +170,7 @@ func applyKeepalives(params map[string]string) { // AutoMigrate runs automatic schema migration on the given client. func AutoMigrate(ctx context.Context, client *ent.Client) error { - if err := client.Schema.Create(ctx, migrate.WithDropIndex(true), migrate.WithDropColumn(true)); err != nil { + if err := client.Schema.Create(ctx); err != nil { return fmt.Errorf("running auto-migration: %w", err) } return nil diff --git a/pkg/ent/harnessconfig.go b/pkg/ent/harnessconfig.go index 05ab07115..be0beba31 100644 --- a/pkg/ent/harnessconfig.go +++ b/pkg/ent/harnessconfig.go @@ -52,6 +52,8 @@ type HarnessConfig struct { CreatedBy string `json:"created_by,omitempty"` // UpdatedBy holds the value of the "updated_by" field. UpdatedBy string `json:"updated_by,omitempty"` + // SourceURL holds the value of the "source_url" field. + SourceURL string `json:"source_url,omitempty"` // Visibility holds the value of the "visibility" field. Visibility string `json:"visibility,omitempty"` // Created holds the value of the "created" field. @@ -66,7 +68,7 @@ func (*HarnessConfig) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case harnessconfig.FieldName, harnessconfig.FieldSlug, harnessconfig.FieldDisplayName, harnessconfig.FieldDescription, harnessconfig.FieldHarness, harnessconfig.FieldConfig, harnessconfig.FieldContentHash, harnessconfig.FieldScope, harnessconfig.FieldScopeID, harnessconfig.FieldStorageURI, harnessconfig.FieldStorageBucket, harnessconfig.FieldStoragePath, harnessconfig.FieldFiles, harnessconfig.FieldStatus, harnessconfig.FieldOwnerID, harnessconfig.FieldCreatedBy, harnessconfig.FieldUpdatedBy, harnessconfig.FieldVisibility: + case harnessconfig.FieldName, harnessconfig.FieldSlug, harnessconfig.FieldDisplayName, harnessconfig.FieldDescription, harnessconfig.FieldHarness, harnessconfig.FieldConfig, harnessconfig.FieldContentHash, harnessconfig.FieldScope, harnessconfig.FieldScopeID, harnessconfig.FieldStorageURI, harnessconfig.FieldStorageBucket, harnessconfig.FieldStoragePath, harnessconfig.FieldFiles, harnessconfig.FieldStatus, harnessconfig.FieldOwnerID, harnessconfig.FieldCreatedBy, harnessconfig.FieldUpdatedBy, harnessconfig.FieldSourceURL, harnessconfig.FieldVisibility: values[i] = new(sql.NullString) case harnessconfig.FieldCreated, harnessconfig.FieldUpdated: values[i] = new(sql.NullTime) @@ -195,6 +197,12 @@ func (_m *HarnessConfig) assignValues(columns []string, values []any) error { } else if value.Valid { _m.UpdatedBy = value.String } + case harnessconfig.FieldSourceURL: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field source_url", values[i]) + } else if value.Valid { + _m.SourceURL = value.String + } case harnessconfig.FieldVisibility: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field visibility", values[i]) @@ -300,6 +308,9 @@ func (_m *HarnessConfig) String() string { builder.WriteString("updated_by=") builder.WriteString(_m.UpdatedBy) builder.WriteString(", ") + builder.WriteString("source_url=") + builder.WriteString(_m.SourceURL) + builder.WriteString(", ") builder.WriteString("visibility=") builder.WriteString(_m.Visibility) builder.WriteString(", ") diff --git a/pkg/ent/harnessconfig/harnessconfig.go b/pkg/ent/harnessconfig/harnessconfig.go index 988bd2e04..e4b6fcb1b 100644 --- a/pkg/ent/harnessconfig/harnessconfig.go +++ b/pkg/ent/harnessconfig/harnessconfig.go @@ -49,6 +49,8 @@ const ( FieldCreatedBy = "created_by" // FieldUpdatedBy holds the string denoting the updated_by field in the database. FieldUpdatedBy = "updated_by" + // FieldSourceURL holds the string denoting the source_url field in the database. + FieldSourceURL = "source_url" // FieldVisibility holds the string denoting the visibility field in the database. FieldVisibility = "visibility" // FieldCreated holds the string denoting the created field in the database. @@ -79,6 +81,7 @@ var Columns = []string{ FieldOwnerID, FieldCreatedBy, FieldUpdatedBy, + FieldSourceURL, FieldVisibility, FieldCreated, FieldUpdated, @@ -235,6 +238,11 @@ func ByUpdatedBy(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUpdatedBy, opts...).ToFunc() } +// BySourceURL orders the results by the source_url field. +func BySourceURL(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSourceURL, opts...).ToFunc() +} + // ByVisibility orders the results by the visibility field. func ByVisibility(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldVisibility, opts...).ToFunc() diff --git a/pkg/ent/harnessconfig/where.go b/pkg/ent/harnessconfig/where.go index 96312c474..10cb9214b 100644 --- a/pkg/ent/harnessconfig/where.go +++ b/pkg/ent/harnessconfig/where.go @@ -135,6 +135,11 @@ func UpdatedBy(v string) predicate.HarnessConfig { return predicate.HarnessConfig(sql.FieldEQ(FieldUpdatedBy, v)) } +// SourceURL applies equality check predicate on the "source_url" field. It's identical to SourceURLEQ. +func SourceURL(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldEQ(FieldSourceURL, v)) +} + // Visibility applies equality check predicate on the "visibility" field. It's identical to VisibilityEQ. func Visibility(v string) predicate.HarnessConfig { return predicate.HarnessConfig(sql.FieldEQ(FieldVisibility, v)) @@ -1330,6 +1335,81 @@ func UpdatedByContainsFold(v string) predicate.HarnessConfig { return predicate.HarnessConfig(sql.FieldContainsFold(FieldUpdatedBy, v)) } +// SourceURLEQ applies the EQ predicate on the "source_url" field. +func SourceURLEQ(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldEQ(FieldSourceURL, v)) +} + +// SourceURLNEQ applies the NEQ predicate on the "source_url" field. +func SourceURLNEQ(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldNEQ(FieldSourceURL, v)) +} + +// SourceURLIn applies the In predicate on the "source_url" field. +func SourceURLIn(vs ...string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldIn(FieldSourceURL, vs...)) +} + +// SourceURLNotIn applies the NotIn predicate on the "source_url" field. +func SourceURLNotIn(vs ...string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldNotIn(FieldSourceURL, vs...)) +} + +// SourceURLGT applies the GT predicate on the "source_url" field. +func SourceURLGT(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldGT(FieldSourceURL, v)) +} + +// SourceURLGTE applies the GTE predicate on the "source_url" field. +func SourceURLGTE(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldGTE(FieldSourceURL, v)) +} + +// SourceURLLT applies the LT predicate on the "source_url" field. +func SourceURLLT(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldLT(FieldSourceURL, v)) +} + +// SourceURLLTE applies the LTE predicate on the "source_url" field. +func SourceURLLTE(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldLTE(FieldSourceURL, v)) +} + +// SourceURLContains applies the Contains predicate on the "source_url" field. +func SourceURLContains(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldContains(FieldSourceURL, v)) +} + +// SourceURLHasPrefix applies the HasPrefix predicate on the "source_url" field. +func SourceURLHasPrefix(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldHasPrefix(FieldSourceURL, v)) +} + +// SourceURLHasSuffix applies the HasSuffix predicate on the "source_url" field. +func SourceURLHasSuffix(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldHasSuffix(FieldSourceURL, v)) +} + +// SourceURLIsNil applies the IsNil predicate on the "source_url" field. +func SourceURLIsNil() predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldIsNull(FieldSourceURL)) +} + +// SourceURLNotNil applies the NotNil predicate on the "source_url" field. +func SourceURLNotNil() predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldNotNull(FieldSourceURL)) +} + +// SourceURLEqualFold applies the EqualFold predicate on the "source_url" field. +func SourceURLEqualFold(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldEqualFold(FieldSourceURL, v)) +} + +// SourceURLContainsFold applies the ContainsFold predicate on the "source_url" field. +func SourceURLContainsFold(v string) predicate.HarnessConfig { + return predicate.HarnessConfig(sql.FieldContainsFold(FieldSourceURL, v)) +} + // VisibilityEQ applies the EQ predicate on the "visibility" field. func VisibilityEQ(v string) predicate.HarnessConfig { return predicate.HarnessConfig(sql.FieldEQ(FieldVisibility, v)) diff --git a/pkg/ent/harnessconfig_create.go b/pkg/ent/harnessconfig_create.go index fb2a3c5fa..c9839429e 100644 --- a/pkg/ent/harnessconfig_create.go +++ b/pkg/ent/harnessconfig_create.go @@ -238,6 +238,20 @@ func (_c *HarnessConfigCreate) SetNillableUpdatedBy(v *string) *HarnessConfigCre return _c } +// SetSourceURL sets the "source_url" field. +func (_c *HarnessConfigCreate) SetSourceURL(v string) *HarnessConfigCreate { + _c.mutation.SetSourceURL(v) + return _c +} + +// SetNillableSourceURL sets the "source_url" field if the given value is not nil. +func (_c *HarnessConfigCreate) SetNillableSourceURL(v *string) *HarnessConfigCreate { + if v != nil { + _c.SetSourceURL(*v) + } + return _c +} + // SetVisibility sets the "visibility" field. func (_c *HarnessConfigCreate) SetVisibility(v string) *HarnessConfigCreate { _c.mutation.SetVisibility(v) @@ -505,6 +519,10 @@ func (_c *HarnessConfigCreate) createSpec() (*HarnessConfig, *sqlgraph.CreateSpe _spec.SetField(harnessconfig.FieldUpdatedBy, field.TypeString, value) _node.UpdatedBy = value } + if value, ok := _c.mutation.SourceURL(); ok { + _spec.SetField(harnessconfig.FieldSourceURL, field.TypeString, value) + _node.SourceURL = value + } if value, ok := _c.mutation.Visibility(); ok { _spec.SetField(harnessconfig.FieldVisibility, field.TypeString, value) _node.Visibility = value @@ -845,6 +863,24 @@ func (u *HarnessConfigUpsert) ClearUpdatedBy() *HarnessConfigUpsert { return u } +// SetSourceURL sets the "source_url" field. +func (u *HarnessConfigUpsert) SetSourceURL(v string) *HarnessConfigUpsert { + u.Set(harnessconfig.FieldSourceURL, v) + return u +} + +// UpdateSourceURL sets the "source_url" field to the value that was provided on create. +func (u *HarnessConfigUpsert) UpdateSourceURL() *HarnessConfigUpsert { + u.SetExcluded(harnessconfig.FieldSourceURL) + return u +} + +// ClearSourceURL clears the value of the "source_url" field. +func (u *HarnessConfigUpsert) ClearSourceURL() *HarnessConfigUpsert { + u.SetNull(harnessconfig.FieldSourceURL) + return u +} + // SetVisibility sets the "visibility" field. func (u *HarnessConfigUpsert) SetVisibility(v string) *HarnessConfigUpsert { u.Set(harnessconfig.FieldVisibility, v) @@ -1242,6 +1278,27 @@ func (u *HarnessConfigUpsertOne) ClearUpdatedBy() *HarnessConfigUpsertOne { }) } +// SetSourceURL sets the "source_url" field. +func (u *HarnessConfigUpsertOne) SetSourceURL(v string) *HarnessConfigUpsertOne { + return u.Update(func(s *HarnessConfigUpsert) { + s.SetSourceURL(v) + }) +} + +// UpdateSourceURL sets the "source_url" field to the value that was provided on create. +func (u *HarnessConfigUpsertOne) UpdateSourceURL() *HarnessConfigUpsertOne { + return u.Update(func(s *HarnessConfigUpsert) { + s.UpdateSourceURL() + }) +} + +// ClearSourceURL clears the value of the "source_url" field. +func (u *HarnessConfigUpsertOne) ClearSourceURL() *HarnessConfigUpsertOne { + return u.Update(func(s *HarnessConfigUpsert) { + s.ClearSourceURL() + }) +} + // SetVisibility sets the "visibility" field. func (u *HarnessConfigUpsertOne) SetVisibility(v string) *HarnessConfigUpsertOne { return u.Update(func(s *HarnessConfigUpsert) { @@ -1810,6 +1867,27 @@ func (u *HarnessConfigUpsertBulk) ClearUpdatedBy() *HarnessConfigUpsertBulk { }) } +// SetSourceURL sets the "source_url" field. +func (u *HarnessConfigUpsertBulk) SetSourceURL(v string) *HarnessConfigUpsertBulk { + return u.Update(func(s *HarnessConfigUpsert) { + s.SetSourceURL(v) + }) +} + +// UpdateSourceURL sets the "source_url" field to the value that was provided on create. +func (u *HarnessConfigUpsertBulk) UpdateSourceURL() *HarnessConfigUpsertBulk { + return u.Update(func(s *HarnessConfigUpsert) { + s.UpdateSourceURL() + }) +} + +// ClearSourceURL clears the value of the "source_url" field. +func (u *HarnessConfigUpsertBulk) ClearSourceURL() *HarnessConfigUpsertBulk { + return u.Update(func(s *HarnessConfigUpsert) { + s.ClearSourceURL() + }) +} + // SetVisibility sets the "visibility" field. func (u *HarnessConfigUpsertBulk) SetVisibility(v string) *HarnessConfigUpsertBulk { return u.Update(func(s *HarnessConfigUpsert) { diff --git a/pkg/ent/harnessconfig_update.go b/pkg/ent/harnessconfig_update.go index ac8379119..359c9c711 100644 --- a/pkg/ent/harnessconfig_update.go +++ b/pkg/ent/harnessconfig_update.go @@ -338,6 +338,26 @@ func (_u *HarnessConfigUpdate) ClearUpdatedBy() *HarnessConfigUpdate { return _u } +// SetSourceURL sets the "source_url" field. +func (_u *HarnessConfigUpdate) SetSourceURL(v string) *HarnessConfigUpdate { + _u.mutation.SetSourceURL(v) + return _u +} + +// SetNillableSourceURL sets the "source_url" field if the given value is not nil. +func (_u *HarnessConfigUpdate) SetNillableSourceURL(v *string) *HarnessConfigUpdate { + if v != nil { + _u.SetSourceURL(*v) + } + return _u +} + +// ClearSourceURL clears the value of the "source_url" field. +func (_u *HarnessConfigUpdate) ClearSourceURL() *HarnessConfigUpdate { + _u.mutation.ClearSourceURL() + return _u +} + // SetVisibility sets the "visibility" field. func (_u *HarnessConfigUpdate) SetVisibility(v string) *HarnessConfigUpdate { _u.mutation.SetVisibility(v) @@ -523,6 +543,12 @@ func (_u *HarnessConfigUpdate) sqlSave(ctx context.Context) (_node int, err erro if _u.mutation.UpdatedByCleared() { _spec.ClearField(harnessconfig.FieldUpdatedBy, field.TypeString) } + if value, ok := _u.mutation.SourceURL(); ok { + _spec.SetField(harnessconfig.FieldSourceURL, field.TypeString, value) + } + if _u.mutation.SourceURLCleared() { + _spec.ClearField(harnessconfig.FieldSourceURL, field.TypeString) + } if value, ok := _u.mutation.Visibility(); ok { _spec.SetField(harnessconfig.FieldVisibility, field.TypeString, value) } @@ -859,6 +885,26 @@ func (_u *HarnessConfigUpdateOne) ClearUpdatedBy() *HarnessConfigUpdateOne { return _u } +// SetSourceURL sets the "source_url" field. +func (_u *HarnessConfigUpdateOne) SetSourceURL(v string) *HarnessConfigUpdateOne { + _u.mutation.SetSourceURL(v) + return _u +} + +// SetNillableSourceURL sets the "source_url" field if the given value is not nil. +func (_u *HarnessConfigUpdateOne) SetNillableSourceURL(v *string) *HarnessConfigUpdateOne { + if v != nil { + _u.SetSourceURL(*v) + } + return _u +} + +// ClearSourceURL clears the value of the "source_url" field. +func (_u *HarnessConfigUpdateOne) ClearSourceURL() *HarnessConfigUpdateOne { + _u.mutation.ClearSourceURL() + return _u +} + // SetVisibility sets the "visibility" field. func (_u *HarnessConfigUpdateOne) SetVisibility(v string) *HarnessConfigUpdateOne { _u.mutation.SetVisibility(v) @@ -1074,6 +1120,12 @@ func (_u *HarnessConfigUpdateOne) sqlSave(ctx context.Context) (_node *HarnessCo if _u.mutation.UpdatedByCleared() { _spec.ClearField(harnessconfig.FieldUpdatedBy, field.TypeString) } + if value, ok := _u.mutation.SourceURL(); ok { + _spec.SetField(harnessconfig.FieldSourceURL, field.TypeString, value) + } + if _u.mutation.SourceURLCleared() { + _spec.ClearField(harnessconfig.FieldSourceURL, field.TypeString) + } if value, ok := _u.mutation.Visibility(); ok { _spec.SetField(harnessconfig.FieldVisibility, field.TypeString, value) } diff --git a/pkg/ent/migrate/schema.go b/pkg/ent/migrate/schema.go index f07aded07..17261371c 100644 --- a/pkg/ent/migrate/schema.go +++ b/pkg/ent/migrate/schema.go @@ -392,6 +392,7 @@ var ( {Name: "owner_id", Type: field.TypeString, Nullable: true}, {Name: "created_by", Type: field.TypeString, Nullable: true}, {Name: "updated_by", Type: field.TypeString, Nullable: true}, + {Name: "source_url", Type: field.TypeString, Nullable: true}, {Name: "visibility", Type: field.TypeString, Default: "private"}, {Name: "created", Type: field.TypeTime}, {Name: "updated", Type: field.TypeTime}, @@ -1087,6 +1088,7 @@ var ( {Name: "owner_id", Type: field.TypeString, Nullable: true}, {Name: "created_by", Type: field.TypeString, Nullable: true}, {Name: "updated_by", Type: field.TypeString, Nullable: true}, + {Name: "source_url", Type: field.TypeString, Nullable: true}, {Name: "visibility", Type: field.TypeString, Default: "private"}, {Name: "created", Type: field.TypeTime}, {Name: "updated", Type: field.TypeTime}, diff --git a/pkg/ent/mutation.go b/pkg/ent/mutation.go index ef443bd5b..3eeedba95 100644 --- a/pkg/ent/mutation.go +++ b/pkg/ent/mutation.go @@ -13046,6 +13046,7 @@ type HarnessConfigMutation struct { owner_id *string created_by *string updated_by *string + source_url *string visibility *string created *time.Time updated *time.Time @@ -13927,6 +13928,55 @@ func (m *HarnessConfigMutation) ResetUpdatedBy() { delete(m.clearedFields, harnessconfig.FieldUpdatedBy) } +// SetSourceURL sets the "source_url" field. +func (m *HarnessConfigMutation) SetSourceURL(s string) { + m.source_url = &s +} + +// SourceURL returns the value of the "source_url" field in the mutation. +func (m *HarnessConfigMutation) SourceURL() (r string, exists bool) { + v := m.source_url + if v == nil { + return + } + return *v, true +} + +// OldSourceURL returns the old "source_url" field's value of the HarnessConfig entity. +// If the HarnessConfig object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *HarnessConfigMutation) OldSourceURL(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSourceURL is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSourceURL requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSourceURL: %w", err) + } + return oldValue.SourceURL, nil +} + +// ClearSourceURL clears the value of the "source_url" field. +func (m *HarnessConfigMutation) ClearSourceURL() { + m.source_url = nil + m.clearedFields[harnessconfig.FieldSourceURL] = struct{}{} +} + +// SourceURLCleared returns if the "source_url" field was cleared in this mutation. +func (m *HarnessConfigMutation) SourceURLCleared() bool { + _, ok := m.clearedFields[harnessconfig.FieldSourceURL] + return ok +} + +// ResetSourceURL resets all changes to the "source_url" field. +func (m *HarnessConfigMutation) ResetSourceURL() { + m.source_url = nil + delete(m.clearedFields, harnessconfig.FieldSourceURL) +} + // SetVisibility sets the "visibility" field. func (m *HarnessConfigMutation) SetVisibility(s string) { m.visibility = &s @@ -14069,7 +14119,7 @@ func (m *HarnessConfigMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *HarnessConfigMutation) Fields() []string { - fields := make([]string, 0, 20) + fields := make([]string, 0, 21) if m.name != nil { fields = append(fields, harnessconfig.FieldName) } @@ -14121,6 +14171,9 @@ func (m *HarnessConfigMutation) Fields() []string { if m.updated_by != nil { fields = append(fields, harnessconfig.FieldUpdatedBy) } + if m.source_url != nil { + fields = append(fields, harnessconfig.FieldSourceURL) + } if m.visibility != nil { fields = append(fields, harnessconfig.FieldVisibility) } @@ -14172,6 +14225,8 @@ func (m *HarnessConfigMutation) Field(name string) (ent.Value, bool) { return m.CreatedBy() case harnessconfig.FieldUpdatedBy: return m.UpdatedBy() + case harnessconfig.FieldSourceURL: + return m.SourceURL() case harnessconfig.FieldVisibility: return m.Visibility() case harnessconfig.FieldCreated: @@ -14221,6 +14276,8 @@ func (m *HarnessConfigMutation) OldField(ctx context.Context, name string) (ent. return m.OldCreatedBy(ctx) case harnessconfig.FieldUpdatedBy: return m.OldUpdatedBy(ctx) + case harnessconfig.FieldSourceURL: + return m.OldSourceURL(ctx) case harnessconfig.FieldVisibility: return m.OldVisibility(ctx) case harnessconfig.FieldCreated: @@ -14355,6 +14412,13 @@ func (m *HarnessConfigMutation) SetField(name string, value ent.Value) error { } m.SetUpdatedBy(v) return nil + case harnessconfig.FieldSourceURL: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSourceURL(v) + return nil case harnessconfig.FieldVisibility: v, ok := value.(string) if !ok { @@ -14442,6 +14506,9 @@ func (m *HarnessConfigMutation) ClearedFields() []string { if m.FieldCleared(harnessconfig.FieldUpdatedBy) { fields = append(fields, harnessconfig.FieldUpdatedBy) } + if m.FieldCleared(harnessconfig.FieldSourceURL) { + fields = append(fields, harnessconfig.FieldSourceURL) + } return fields } @@ -14492,6 +14559,9 @@ func (m *HarnessConfigMutation) ClearField(name string) error { case harnessconfig.FieldUpdatedBy: m.ClearUpdatedBy() return nil + case harnessconfig.FieldSourceURL: + m.ClearSourceURL() + return nil } return fmt.Errorf("unknown HarnessConfig nullable field %s", name) } @@ -14551,6 +14621,9 @@ func (m *HarnessConfigMutation) ResetField(name string) error { case harnessconfig.FieldUpdatedBy: m.ResetUpdatedBy() return nil + case harnessconfig.FieldSourceURL: + m.ResetSourceURL() + return nil case harnessconfig.FieldVisibility: m.ResetVisibility() return nil @@ -34561,6 +34634,7 @@ type TemplateMutation struct { owner_id *string created_by *string updated_by *string + source_url *string visibility *string created *time.Time updated *time.Time @@ -35638,6 +35712,55 @@ func (m *TemplateMutation) ResetUpdatedBy() { delete(m.clearedFields, template.FieldUpdatedBy) } +// SetSourceURL sets the "source_url" field. +func (m *TemplateMutation) SetSourceURL(s string) { + m.source_url = &s +} + +// SourceURL returns the value of the "source_url" field in the mutation. +func (m *TemplateMutation) SourceURL() (r string, exists bool) { + v := m.source_url + if v == nil { + return + } + return *v, true +} + +// OldSourceURL returns the old "source_url" field's value of the Template entity. +// If the Template object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *TemplateMutation) OldSourceURL(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSourceURL is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSourceURL requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSourceURL: %w", err) + } + return oldValue.SourceURL, nil +} + +// ClearSourceURL clears the value of the "source_url" field. +func (m *TemplateMutation) ClearSourceURL() { + m.source_url = nil + m.clearedFields[template.FieldSourceURL] = struct{}{} +} + +// SourceURLCleared returns if the "source_url" field was cleared in this mutation. +func (m *TemplateMutation) SourceURLCleared() bool { + _, ok := m.clearedFields[template.FieldSourceURL] + return ok +} + +// ResetSourceURL resets all changes to the "source_url" field. +func (m *TemplateMutation) ResetSourceURL() { + m.source_url = nil + delete(m.clearedFields, template.FieldSourceURL) +} + // SetVisibility sets the "visibility" field. func (m *TemplateMutation) SetVisibility(s string) { m.visibility = &s @@ -35780,7 +35903,7 @@ func (m *TemplateMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *TemplateMutation) Fields() []string { - fields := make([]string, 0, 24) + fields := make([]string, 0, 25) if m.name != nil { fields = append(fields, template.FieldName) } @@ -35844,6 +35967,9 @@ func (m *TemplateMutation) Fields() []string { if m.updated_by != nil { fields = append(fields, template.FieldUpdatedBy) } + if m.source_url != nil { + fields = append(fields, template.FieldSourceURL) + } if m.visibility != nil { fields = append(fields, template.FieldVisibility) } @@ -35903,6 +36029,8 @@ func (m *TemplateMutation) Field(name string) (ent.Value, bool) { return m.CreatedBy() case template.FieldUpdatedBy: return m.UpdatedBy() + case template.FieldSourceURL: + return m.SourceURL() case template.FieldVisibility: return m.Visibility() case template.FieldCreated: @@ -35960,6 +36088,8 @@ func (m *TemplateMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldCreatedBy(ctx) case template.FieldUpdatedBy: return m.OldUpdatedBy(ctx) + case template.FieldSourceURL: + return m.OldSourceURL(ctx) case template.FieldVisibility: return m.OldVisibility(ctx) case template.FieldCreated: @@ -36122,6 +36252,13 @@ func (m *TemplateMutation) SetField(name string, value ent.Value) error { } m.SetUpdatedBy(v) return nil + case template.FieldSourceURL: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSourceURL(v) + return nil case template.FieldVisibility: v, ok := value.(string) if !ok { @@ -36221,6 +36358,9 @@ func (m *TemplateMutation) ClearedFields() []string { if m.FieldCleared(template.FieldUpdatedBy) { fields = append(fields, template.FieldUpdatedBy) } + if m.FieldCleared(template.FieldSourceURL) { + fields = append(fields, template.FieldSourceURL) + } return fields } @@ -36283,6 +36423,9 @@ func (m *TemplateMutation) ClearField(name string) error { case template.FieldUpdatedBy: m.ClearUpdatedBy() return nil + case template.FieldSourceURL: + m.ClearSourceURL() + return nil } return fmt.Errorf("unknown Template nullable field %s", name) } @@ -36354,6 +36497,9 @@ func (m *TemplateMutation) ResetField(name string) error { case template.FieldUpdatedBy: m.ResetUpdatedBy() return nil + case template.FieldSourceURL: + m.ResetSourceURL() + return nil case template.FieldVisibility: m.ResetVisibility() return nil diff --git a/pkg/ent/runtime.go b/pkg/ent/runtime.go index 996dbfc20..09a18583a 100644 --- a/pkg/ent/runtime.go +++ b/pkg/ent/runtime.go @@ -392,15 +392,15 @@ func init() { // harnessconfig.DefaultScope holds the default value on creation for the scope field. harnessconfig.DefaultScope = harnessconfigDescScope.Default.(string) // harnessconfigDescVisibility is the schema descriptor for visibility field. - harnessconfigDescVisibility := harnessconfigFields[18].Descriptor() + harnessconfigDescVisibility := harnessconfigFields[19].Descriptor() // harnessconfig.DefaultVisibility holds the default value on creation for the visibility field. harnessconfig.DefaultVisibility = harnessconfigDescVisibility.Default.(string) // harnessconfigDescCreated is the schema descriptor for created field. - harnessconfigDescCreated := harnessconfigFields[19].Descriptor() + harnessconfigDescCreated := harnessconfigFields[20].Descriptor() // harnessconfig.DefaultCreated holds the default value on creation for the created field. harnessconfig.DefaultCreated = harnessconfigDescCreated.Default.(func() time.Time) // harnessconfigDescUpdated is the schema descriptor for updated field. - harnessconfigDescUpdated := harnessconfigFields[20].Descriptor() + harnessconfigDescUpdated := harnessconfigFields[21].Descriptor() // harnessconfig.DefaultUpdated holds the default value on creation for the updated field. harnessconfig.DefaultUpdated = harnessconfigDescUpdated.Default.(func() time.Time) // harnessconfig.UpdateDefaultUpdated holds the default value on update for the updated field. @@ -992,15 +992,15 @@ func init() { // template.DefaultScope holds the default value on creation for the scope field. template.DefaultScope = templateDescScope.Default.(string) // templateDescVisibility is the schema descriptor for visibility field. - templateDescVisibility := templateFields[22].Descriptor() + templateDescVisibility := templateFields[23].Descriptor() // template.DefaultVisibility holds the default value on creation for the visibility field. template.DefaultVisibility = templateDescVisibility.Default.(string) // templateDescCreated is the schema descriptor for created field. - templateDescCreated := templateFields[23].Descriptor() + templateDescCreated := templateFields[24].Descriptor() // template.DefaultCreated holds the default value on creation for the created field. template.DefaultCreated = templateDescCreated.Default.(func() time.Time) // templateDescUpdated is the schema descriptor for updated field. - templateDescUpdated := templateFields[24].Descriptor() + templateDescUpdated := templateFields[25].Descriptor() // template.DefaultUpdated holds the default value on creation for the updated field. template.DefaultUpdated = templateDescUpdated.Default.(func() time.Time) // template.UpdateDefaultUpdated holds the default value on update for the updated field. diff --git a/pkg/ent/schema/harnessconfig.go b/pkg/ent/schema/harnessconfig.go index 1814ae6ce..b1745df70 100644 --- a/pkg/ent/schema/harnessconfig.go +++ b/pkg/ent/schema/harnessconfig.go @@ -74,6 +74,8 @@ func (HarnessConfig) Fields() []ent.Field { Optional(), field.String("updated_by"). Optional(), + field.String("source_url"). + Optional(), field.String("visibility"). Default("private"), field.Time("created"). diff --git a/pkg/ent/schema/template.go b/pkg/ent/schema/template.go index 791e56d61..d240bb540 100644 --- a/pkg/ent/schema/template.go +++ b/pkg/ent/schema/template.go @@ -88,6 +88,8 @@ func (Template) Fields() []ent.Field { Optional(), field.String("updated_by"). Optional(), + field.String("source_url"). + Optional(), field.String("visibility"). Default("private"), field.Time("created"). diff --git a/pkg/ent/template.go b/pkg/ent/template.go index e54da9747..934325485 100644 --- a/pkg/ent/template.go +++ b/pkg/ent/template.go @@ -60,6 +60,8 @@ type Template struct { CreatedBy string `json:"created_by,omitempty"` // UpdatedBy holds the value of the "updated_by" field. UpdatedBy string `json:"updated_by,omitempty"` + // SourceURL holds the value of the "source_url" field. + SourceURL string `json:"source_url,omitempty"` // Visibility holds the value of the "visibility" field. Visibility string `json:"visibility,omitempty"` // Created holds the value of the "created" field. @@ -74,7 +76,7 @@ func (*Template) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case template.FieldName, template.FieldSlug, template.FieldDisplayName, template.FieldDescription, template.FieldHarness, template.FieldDefaultHarnessConfig, template.FieldImage, template.FieldConfig, template.FieldContentHash, template.FieldScope, template.FieldScopeID, template.FieldProjectID, template.FieldStorageURI, template.FieldStorageBucket, template.FieldStoragePath, template.FieldFiles, template.FieldBaseTemplate, template.FieldStatus, template.FieldOwnerID, template.FieldCreatedBy, template.FieldUpdatedBy, template.FieldVisibility: + case template.FieldName, template.FieldSlug, template.FieldDisplayName, template.FieldDescription, template.FieldHarness, template.FieldDefaultHarnessConfig, template.FieldImage, template.FieldConfig, template.FieldContentHash, template.FieldScope, template.FieldScopeID, template.FieldProjectID, template.FieldStorageURI, template.FieldStorageBucket, template.FieldStoragePath, template.FieldFiles, template.FieldBaseTemplate, template.FieldStatus, template.FieldOwnerID, template.FieldCreatedBy, template.FieldUpdatedBy, template.FieldSourceURL, template.FieldVisibility: values[i] = new(sql.NullString) case template.FieldCreated, template.FieldUpdated: values[i] = new(sql.NullTime) @@ -227,6 +229,12 @@ func (_m *Template) assignValues(columns []string, values []any) error { } else if value.Valid { _m.UpdatedBy = value.String } + case template.FieldSourceURL: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field source_url", values[i]) + } else if value.Valid { + _m.SourceURL = value.String + } case template.FieldVisibility: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field visibility", values[i]) @@ -344,6 +352,9 @@ func (_m *Template) String() string { builder.WriteString("updated_by=") builder.WriteString(_m.UpdatedBy) builder.WriteString(", ") + builder.WriteString("source_url=") + builder.WriteString(_m.SourceURL) + builder.WriteString(", ") builder.WriteString("visibility=") builder.WriteString(_m.Visibility) builder.WriteString(", ") diff --git a/pkg/ent/template/template.go b/pkg/ent/template/template.go index 1da8b5ce9..363d6f2a0 100644 --- a/pkg/ent/template/template.go +++ b/pkg/ent/template/template.go @@ -57,6 +57,8 @@ const ( FieldCreatedBy = "created_by" // FieldUpdatedBy holds the string denoting the updated_by field in the database. FieldUpdatedBy = "updated_by" + // FieldSourceURL holds the string denoting the source_url field in the database. + FieldSourceURL = "source_url" // FieldVisibility holds the string denoting the visibility field in the database. FieldVisibility = "visibility" // FieldCreated holds the string denoting the created field in the database. @@ -91,6 +93,7 @@ var Columns = []string{ FieldOwnerID, FieldCreatedBy, FieldUpdatedBy, + FieldSourceURL, FieldVisibility, FieldCreated, FieldUpdated, @@ -265,6 +268,11 @@ func ByUpdatedBy(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUpdatedBy, opts...).ToFunc() } +// BySourceURL orders the results by the source_url field. +func BySourceURL(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSourceURL, opts...).ToFunc() +} + // ByVisibility orders the results by the visibility field. func ByVisibility(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldVisibility, opts...).ToFunc() diff --git a/pkg/ent/template/where.go b/pkg/ent/template/where.go index 5a59a0bea..bd424df0a 100644 --- a/pkg/ent/template/where.go +++ b/pkg/ent/template/where.go @@ -155,6 +155,11 @@ func UpdatedBy(v string) predicate.Template { return predicate.Template(sql.FieldEQ(FieldUpdatedBy, v)) } +// SourceURL applies equality check predicate on the "source_url" field. It's identical to SourceURLEQ. +func SourceURL(v string) predicate.Template { + return predicate.Template(sql.FieldEQ(FieldSourceURL, v)) +} + // Visibility applies equality check predicate on the "visibility" field. It's identical to VisibilityEQ. func Visibility(v string) predicate.Template { return predicate.Template(sql.FieldEQ(FieldVisibility, v)) @@ -1650,6 +1655,81 @@ func UpdatedByContainsFold(v string) predicate.Template { return predicate.Template(sql.FieldContainsFold(FieldUpdatedBy, v)) } +// SourceURLEQ applies the EQ predicate on the "source_url" field. +func SourceURLEQ(v string) predicate.Template { + return predicate.Template(sql.FieldEQ(FieldSourceURL, v)) +} + +// SourceURLNEQ applies the NEQ predicate on the "source_url" field. +func SourceURLNEQ(v string) predicate.Template { + return predicate.Template(sql.FieldNEQ(FieldSourceURL, v)) +} + +// SourceURLIn applies the In predicate on the "source_url" field. +func SourceURLIn(vs ...string) predicate.Template { + return predicate.Template(sql.FieldIn(FieldSourceURL, vs...)) +} + +// SourceURLNotIn applies the NotIn predicate on the "source_url" field. +func SourceURLNotIn(vs ...string) predicate.Template { + return predicate.Template(sql.FieldNotIn(FieldSourceURL, vs...)) +} + +// SourceURLGT applies the GT predicate on the "source_url" field. +func SourceURLGT(v string) predicate.Template { + return predicate.Template(sql.FieldGT(FieldSourceURL, v)) +} + +// SourceURLGTE applies the GTE predicate on the "source_url" field. +func SourceURLGTE(v string) predicate.Template { + return predicate.Template(sql.FieldGTE(FieldSourceURL, v)) +} + +// SourceURLLT applies the LT predicate on the "source_url" field. +func SourceURLLT(v string) predicate.Template { + return predicate.Template(sql.FieldLT(FieldSourceURL, v)) +} + +// SourceURLLTE applies the LTE predicate on the "source_url" field. +func SourceURLLTE(v string) predicate.Template { + return predicate.Template(sql.FieldLTE(FieldSourceURL, v)) +} + +// SourceURLContains applies the Contains predicate on the "source_url" field. +func SourceURLContains(v string) predicate.Template { + return predicate.Template(sql.FieldContains(FieldSourceURL, v)) +} + +// SourceURLHasPrefix applies the HasPrefix predicate on the "source_url" field. +func SourceURLHasPrefix(v string) predicate.Template { + return predicate.Template(sql.FieldHasPrefix(FieldSourceURL, v)) +} + +// SourceURLHasSuffix applies the HasSuffix predicate on the "source_url" field. +func SourceURLHasSuffix(v string) predicate.Template { + return predicate.Template(sql.FieldHasSuffix(FieldSourceURL, v)) +} + +// SourceURLIsNil applies the IsNil predicate on the "source_url" field. +func SourceURLIsNil() predicate.Template { + return predicate.Template(sql.FieldIsNull(FieldSourceURL)) +} + +// SourceURLNotNil applies the NotNil predicate on the "source_url" field. +func SourceURLNotNil() predicate.Template { + return predicate.Template(sql.FieldNotNull(FieldSourceURL)) +} + +// SourceURLEqualFold applies the EqualFold predicate on the "source_url" field. +func SourceURLEqualFold(v string) predicate.Template { + return predicate.Template(sql.FieldEqualFold(FieldSourceURL, v)) +} + +// SourceURLContainsFold applies the ContainsFold predicate on the "source_url" field. +func SourceURLContainsFold(v string) predicate.Template { + return predicate.Template(sql.FieldContainsFold(FieldSourceURL, v)) +} + // VisibilityEQ applies the EQ predicate on the "visibility" field. func VisibilityEQ(v string) predicate.Template { return predicate.Template(sql.FieldEQ(FieldVisibility, v)) diff --git a/pkg/ent/template_create.go b/pkg/ent/template_create.go index 3a1b882d9..08cf41b19 100644 --- a/pkg/ent/template_create.go +++ b/pkg/ent/template_create.go @@ -294,6 +294,20 @@ func (_c *TemplateCreate) SetNillableUpdatedBy(v *string) *TemplateCreate { return _c } +// SetSourceURL sets the "source_url" field. +func (_c *TemplateCreate) SetSourceURL(v string) *TemplateCreate { + _c.mutation.SetSourceURL(v) + return _c +} + +// SetNillableSourceURL sets the "source_url" field if the given value is not nil. +func (_c *TemplateCreate) SetNillableSourceURL(v *string) *TemplateCreate { + if v != nil { + _c.SetSourceURL(*v) + } + return _c +} + // SetVisibility sets the "visibility" field. func (_c *TemplateCreate) SetVisibility(v string) *TemplateCreate { _c.mutation.SetVisibility(v) @@ -572,6 +586,10 @@ func (_c *TemplateCreate) createSpec() (*Template, *sqlgraph.CreateSpec) { _spec.SetField(template.FieldUpdatedBy, field.TypeString, value) _node.UpdatedBy = value } + if value, ok := _c.mutation.SourceURL(); ok { + _spec.SetField(template.FieldSourceURL, field.TypeString, value) + _node.SourceURL = value + } if value, ok := _c.mutation.Visibility(); ok { _spec.SetField(template.FieldVisibility, field.TypeString, value) _node.Visibility = value @@ -984,6 +1002,24 @@ func (u *TemplateUpsert) ClearUpdatedBy() *TemplateUpsert { return u } +// SetSourceURL sets the "source_url" field. +func (u *TemplateUpsert) SetSourceURL(v string) *TemplateUpsert { + u.Set(template.FieldSourceURL, v) + return u +} + +// UpdateSourceURL sets the "source_url" field to the value that was provided on create. +func (u *TemplateUpsert) UpdateSourceURL() *TemplateUpsert { + u.SetExcluded(template.FieldSourceURL) + return u +} + +// ClearSourceURL clears the value of the "source_url" field. +func (u *TemplateUpsert) ClearSourceURL() *TemplateUpsert { + u.SetNull(template.FieldSourceURL) + return u +} + // SetVisibility sets the "visibility" field. func (u *TemplateUpsert) SetVisibility(v string) *TemplateUpsert { u.Set(template.FieldVisibility, v) @@ -1465,6 +1501,27 @@ func (u *TemplateUpsertOne) ClearUpdatedBy() *TemplateUpsertOne { }) } +// SetSourceURL sets the "source_url" field. +func (u *TemplateUpsertOne) SetSourceURL(v string) *TemplateUpsertOne { + return u.Update(func(s *TemplateUpsert) { + s.SetSourceURL(v) + }) +} + +// UpdateSourceURL sets the "source_url" field to the value that was provided on create. +func (u *TemplateUpsertOne) UpdateSourceURL() *TemplateUpsertOne { + return u.Update(func(s *TemplateUpsert) { + s.UpdateSourceURL() + }) +} + +// ClearSourceURL clears the value of the "source_url" field. +func (u *TemplateUpsertOne) ClearSourceURL() *TemplateUpsertOne { + return u.Update(func(s *TemplateUpsert) { + s.ClearSourceURL() + }) +} + // SetVisibility sets the "visibility" field. func (u *TemplateUpsertOne) SetVisibility(v string) *TemplateUpsertOne { return u.Update(func(s *TemplateUpsert) { @@ -2117,6 +2174,27 @@ func (u *TemplateUpsertBulk) ClearUpdatedBy() *TemplateUpsertBulk { }) } +// SetSourceURL sets the "source_url" field. +func (u *TemplateUpsertBulk) SetSourceURL(v string) *TemplateUpsertBulk { + return u.Update(func(s *TemplateUpsert) { + s.SetSourceURL(v) + }) +} + +// UpdateSourceURL sets the "source_url" field to the value that was provided on create. +func (u *TemplateUpsertBulk) UpdateSourceURL() *TemplateUpsertBulk { + return u.Update(func(s *TemplateUpsert) { + s.UpdateSourceURL() + }) +} + +// ClearSourceURL clears the value of the "source_url" field. +func (u *TemplateUpsertBulk) ClearSourceURL() *TemplateUpsertBulk { + return u.Update(func(s *TemplateUpsert) { + s.ClearSourceURL() + }) +} + // SetVisibility sets the "visibility" field. func (u *TemplateUpsertBulk) SetVisibility(v string) *TemplateUpsertBulk { return u.Update(func(s *TemplateUpsert) { diff --git a/pkg/ent/template_update.go b/pkg/ent/template_update.go index 9fc883aea..e2e56a5b9 100644 --- a/pkg/ent/template_update.go +++ b/pkg/ent/template_update.go @@ -418,6 +418,26 @@ func (_u *TemplateUpdate) ClearUpdatedBy() *TemplateUpdate { return _u } +// SetSourceURL sets the "source_url" field. +func (_u *TemplateUpdate) SetSourceURL(v string) *TemplateUpdate { + _u.mutation.SetSourceURL(v) + return _u +} + +// SetNillableSourceURL sets the "source_url" field if the given value is not nil. +func (_u *TemplateUpdate) SetNillableSourceURL(v *string) *TemplateUpdate { + if v != nil { + _u.SetSourceURL(*v) + } + return _u +} + +// ClearSourceURL clears the value of the "source_url" field. +func (_u *TemplateUpdate) ClearSourceURL() *TemplateUpdate { + _u.mutation.ClearSourceURL() + return _u +} + // SetVisibility sets the "visibility" field. func (_u *TemplateUpdate) SetVisibility(v string) *TemplateUpdate { _u.mutation.SetVisibility(v) @@ -622,6 +642,12 @@ func (_u *TemplateUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.UpdatedByCleared() { _spec.ClearField(template.FieldUpdatedBy, field.TypeString) } + if value, ok := _u.mutation.SourceURL(); ok { + _spec.SetField(template.FieldSourceURL, field.TypeString, value) + } + if _u.mutation.SourceURLCleared() { + _spec.ClearField(template.FieldSourceURL, field.TypeString) + } if value, ok := _u.mutation.Visibility(); ok { _spec.SetField(template.FieldVisibility, field.TypeString, value) } @@ -1038,6 +1064,26 @@ func (_u *TemplateUpdateOne) ClearUpdatedBy() *TemplateUpdateOne { return _u } +// SetSourceURL sets the "source_url" field. +func (_u *TemplateUpdateOne) SetSourceURL(v string) *TemplateUpdateOne { + _u.mutation.SetSourceURL(v) + return _u +} + +// SetNillableSourceURL sets the "source_url" field if the given value is not nil. +func (_u *TemplateUpdateOne) SetNillableSourceURL(v *string) *TemplateUpdateOne { + if v != nil { + _u.SetSourceURL(*v) + } + return _u +} + +// ClearSourceURL clears the value of the "source_url" field. +func (_u *TemplateUpdateOne) ClearSourceURL() *TemplateUpdateOne { + _u.mutation.ClearSourceURL() + return _u +} + // SetVisibility sets the "visibility" field. func (_u *TemplateUpdateOne) SetVisibility(v string) *TemplateUpdateOne { _u.mutation.SetVisibility(v) @@ -1272,6 +1318,12 @@ func (_u *TemplateUpdateOne) sqlSave(ctx context.Context) (_node *Template, err if _u.mutation.UpdatedByCleared() { _spec.ClearField(template.FieldUpdatedBy, field.TypeString) } + if value, ok := _u.mutation.SourceURL(); ok { + _spec.SetField(template.FieldSourceURL, field.TypeString, value) + } + if _u.mutation.SourceURLCleared() { + _spec.ClearField(template.FieldSourceURL, field.TypeString) + } if value, ok := _u.mutation.Visibility(); ok { _spec.SetField(template.FieldVisibility, field.TypeString, value) } diff --git a/pkg/harness/capabilities_test.go b/pkg/harness/capabilities_test.go index ddd6a44b2..0899ae6da 100644 --- a/pkg/harness/capabilities_test.go +++ b/pkg/harness/capabilities_test.go @@ -47,7 +47,7 @@ func TestAdvancedCapabilitiesDefaults(t *testing.T) { name: "claude", harness: "claude", expectMaxTurns: api.SupportYes, - expectMaxModelCalls: api.SupportNo, + expectMaxModelCalls: api.SupportYes, expectMaxDuration: api.SupportYes, expectAuthFile: api.SupportYes, expectVertexAI: api.SupportYes, diff --git a/pkg/harness/claude/embeds/settings.json b/pkg/harness/claude/embeds/settings.json index 10897e52a..7d39813cb 100644 --- a/pkg/harness/claude/embeds/settings.json +++ b/pkg/harness/claude/embeds/settings.json @@ -20,6 +20,7 @@ "SubagentStop": [{"matcher": "*", "hooks": [{"name": "scion-status", "type": "command", "command": "sciontool hook --dialect=claude"}]}], "PreToolUse": [{"matcher": "*", "hooks": [{"name": "scion-status", "type": "command", "command": "sciontool hook --dialect=claude"}]}], "PostToolUse": [{"matcher": "*", "hooks": [{"name": "scion-status", "type": "command", "command": "sciontool hook --dialect=claude"}]}], + "ModelResponse": [{"matcher": "*", "hooks": [{"name": "scion-telemetry", "type": "command", "command": "sciontool hook --dialect=claude"}]}], "Notification": [{"matcher": "ToolPermission", "hooks": [{"name": "scion-status", "type": "command", "command": "sciontool hook --dialect=claude"}]}] } } diff --git a/pkg/harness/claude_code.go b/pkg/harness/claude_code.go index e6488e57a..9a26ef04a 100644 --- a/pkg/harness/claude_code.go +++ b/pkg/harness/claude_code.go @@ -41,7 +41,7 @@ func (c *ClaudeCode) AdvancedCapabilities() api.HarnessAdvancedCapabilities { Harness: "claude", Limits: api.HarnessLimitCapabilities{ MaxTurns: api.CapabilityField{Support: api.SupportYes}, - MaxModelCalls: api.CapabilityField{Support: api.SupportNo, Reason: "This harness does not emit model-end hook events"}, + MaxModelCalls: api.CapabilityField{Support: api.SupportYes}, MaxDuration: api.CapabilityField{Support: api.SupportYes}, }, Telemetry: api.HarnessTelemetryCapabilities{ diff --git a/pkg/harness/resolve.go b/pkg/harness/resolve.go index e2c902ad5..8d56471d5 100644 --- a/pkg/harness/resolve.go +++ b/pkg/harness/resolve.go @@ -40,6 +40,7 @@ type ResolveOptions struct { TemplatePaths []string // optional template dirs (highest priority) ProfileName string // active profile (for settings overlay) Settings *config.VersionedSettings // optional settings overlay + ConfigDirPath string // explicit harness-config dir (Hub-hydrated path); takes priority over name-based lookup } // ResolvedHarness is the result of harness.Resolve. The selected @@ -67,7 +68,13 @@ func Resolve(_ context.Context, opts ResolveOptions) (*ResolvedHarness, error) { return nil, fmt.Errorf("harness.Resolve requires a name") } - hcDir, hcErr := config.FindHarnessConfigDir(opts.Name, opts.ProjectPath, opts.TemplatePaths...) + var hcDir *config.HarnessConfigDir + var hcErr error + if opts.ConfigDirPath != "" { + hcDir, hcErr = config.LoadHarnessConfigDir(opts.ConfigDirPath) + } else { + hcDir, hcErr = config.FindHarnessConfigDir(opts.Name, opts.ProjectPath, opts.TemplatePaths...) + } entry := config.HarnessConfigEntry{Harness: opts.Name} if hcDir != nil { diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index f2dc9722e..38df3dc8c 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -44,6 +44,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/transfer" "github.com/GoogleCloudPlatform/scion/pkg/util" "github.com/GoogleCloudPlatform/scion/pkg/version" + gouuid "github.com/google/uuid" ) // ============================================================================ @@ -282,8 +283,15 @@ func (s *Server) listAgents(w http.ResponseWriter, r *http.Request) { ctx := r.Context() query := r.URL.Query() + projectID := query.Get("projectId") + if projectID != "" && gouuid.Validate(projectID) != nil { + if project, err := s.store.GetProjectBySlug(ctx, projectID); err == nil && project != nil { + projectID = project.ID + } + } + filter := store.AgentFilter{ - ProjectID: query.Get("projectId"), + ProjectID: projectID, RuntimeBrokerID: query.Get("runtimeBrokerId"), Phase: query.Get("phase"), IncludeDeleted: query.Get("includeDeleted") == "true", @@ -2575,8 +2583,14 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, id s structuredMsg.RecipientID = agent.ID // Default the channel to "web" for messages sent through the web UI. - if structuredMsg.Channel == "" && GetUserIdentityFromContext(ctx) != nil { - structuredMsg.Channel = "web" + // Only tag as "web" when the authenticated user's client type is + // actually "web" — CLI and API callers should not be tagged. + if structuredMsg.Channel == "" { + if user := GetUserIdentityFromContext(ctx); user != nil { + if au, ok := user.(*AuthenticatedUser); ok && au.ClientType() == "web" { + structuredMsg.Channel = "web" + } + } } if !s.checkBrokerAvailability(w, r, agent) { @@ -4970,6 +4984,20 @@ func (s *Server) handleProjectRoutes(w http.ResponseWriter, r *http.Request) { return } + // Check for nested /metrics-summary path (lightweight project metrics summary) + if subPath == "metrics-summary" { + s.handleProjectMetricsSummary(w, r, projectID) + return + } + + // Check for nested /metrics path (project-scoped metrics dashboard) + if subPath == "metrics" || strings.HasPrefix(subPath, "metrics/") { + metricsPath := strings.TrimPrefix(subPath, "metrics") + metricsPath = strings.TrimPrefix(metricsPath, "/") + s.handleProjectMetricsDashboard(w, r, projectID, metricsPath) + return + } + // Check for nested /settings path if subPath == "settings" { s.handleProjectSettings(w, r, projectID) @@ -10204,13 +10232,20 @@ func (s *Server) handleProjectImportTemplates(w http.ResponseWriter, r *http.Req return } - var imported []string - if req.WorkspacePath != "" { - imported, err = s.importFromWorkspace(ctx, project, req.WorkspacePath, store.TemplateScopeProject, s.templateImportKind(), nil, req.Names) - } else { + run := func(progress importProgressFunc) ([]string, error) { + if req.WorkspacePath != "" { + return s.importFromWorkspace(ctx, project, req.WorkspacePath, store.TemplateScopeProject, s.templateImportKind(), progress, req.Names) + } req.SourceURL = config.NormalizeTemplateSourceURL(req.SourceURL) - imported, err = s.importFromRemote(ctx, projectID, req.SourceURL, store.TemplateScopeProject, s.templateImportKind(), nil, req.Names) + return s.importFromRemote(ctx, projectID, req.SourceURL, store.TemplateScopeProject, s.templateImportKind(), progress, req.Names) } + + if importAcceptsNDJSON(r) { + s.streamImport(w, run) + return + } + + imported, err := run(nil) if err != nil { writeError(w, http.StatusBadRequest, "import_failed", err.Error(), nil) return @@ -10296,13 +10331,20 @@ func (s *Server) handleProjectImportHarnessConfigs(w http.ResponseWriter, r *htt return } - var imported []string - if req.WorkspacePath != "" { - imported, err = s.importFromWorkspace(ctx, project, req.WorkspacePath, store.HarnessConfigScopeProject, s.harnessConfigImportKind(), nil, req.Names) - } else { + run := func(progress importProgressFunc) ([]string, error) { + if req.WorkspacePath != "" { + return s.importFromWorkspace(ctx, project, req.WorkspacePath, store.HarnessConfigScopeProject, s.harnessConfigImportKind(), progress, req.Names) + } req.SourceURL = config.NormalizeTemplateSourceURL(req.SourceURL) - imported, err = s.importFromRemote(ctx, projectID, req.SourceURL, store.HarnessConfigScopeProject, s.harnessConfigImportKind(), nil, req.Names) + return s.importFromRemote(ctx, projectID, req.SourceURL, store.HarnessConfigScopeProject, s.harnessConfigImportKind(), progress, req.Names) } + + if importAcceptsNDJSON(r) { + s.streamImport(w, run) + return + } + + imported, err := run(nil) if err != nil { writeError(w, http.StatusBadRequest, "import_failed", err.Error(), nil) return diff --git a/pkg/hub/handlers_messages.go b/pkg/hub/handlers_messages.go index b29fe4590..b44d86f2d 100644 --- a/pkg/hub/handlers_messages.go +++ b/pkg/hub/handlers_messages.go @@ -210,8 +210,8 @@ func (s *Server) handleAgentMessages(w http.ResponseWriter, r *http.Request, age } // handleAgentMessagesStream handles GET /api/v1/agents/{id}/messages/stream. -// Streams new messages involving a specific agent in real time, scoped to -// the conversation between the current authenticated user and the agent. +// Streams new messages involving a specific agent in real time. Users who +// can manage the agent see all messages; others see only their own. // Unlike /message-logs/stream this does not depend on Cloud Logging: it // subscribes to the in-process event bus that handleAgentOutboundMessage // and handleAgentMessage already publish to, so it works on any hub diff --git a/pkg/hub/harness_config_bootstrap.go b/pkg/hub/harness_config_bootstrap.go index db03bd892..b71b8c149 100644 --- a/pkg/hub/harness_config_bootstrap.go +++ b/pkg/hub/harness_config_bootstrap.go @@ -110,7 +110,7 @@ func (s *Server) bootstrapSingleHarnessConfig(ctx context.Context, name, dirPath // (§7.3). stor is unused — the store resolves the backend itself — but is kept // in the signature to match the bundled-import call sites. func (s *Server) bootstrapSingleHarnessConfigScoped(ctx context.Context, name, dirPath string, hcDir *config.HarnessConfigDir, _ storage.Storage, scope, scopeID string) error { - _, err := s.harnessConfigStore(hcDir.Config.Harness).Bootstrap(ctx, name, dirPath, scope, scopeID, false) + _, err := s.harnessConfigStore(hcDir.Config.Harness).Bootstrap(ctx, name, dirPath, scope, scopeID, "", false) return err } @@ -147,5 +147,5 @@ func (s *Server) importHarnessConfigsFromWorkspace(ctx context.Context, project // force is true the config is re-uploaded and storage reconciled even if the // content hash is unchanged (used by direct imports). func (s *Server) syncExistingHarnessConfig(ctx context.Context, existing *store.HarnessConfig, dirPath string, hcDir *config.HarnessConfigDir, _ storage.Storage, force bool) (bool, error) { - return s.harnessConfigStore(hcDir.Config.Harness).Bootstrap(ctx, existing.Name, dirPath, existing.Scope, existing.ScopeID, force) + return s.harnessConfigStore(hcDir.Config.Harness).Bootstrap(ctx, existing.Name, dirPath, existing.Scope, existing.ScopeID, "", force) } diff --git a/pkg/hub/harness_config_handlers.go b/pkg/hub/harness_config_handlers.go index b385af2dd..2326f96af 100644 --- a/pkg/hub/harness_config_handlers.go +++ b/pkg/hub/harness_config_handlers.go @@ -20,6 +20,7 @@ import ( "strings" "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/config" "github.com/GoogleCloudPlatform/scion/pkg/storage" "github.com/GoogleCloudPlatform/scion/pkg/store" ) @@ -238,6 +239,8 @@ func (s *Server) handleHarnessConfigByID(w http.ResponseWriter, r *http.Request) s.handleHarnessConfigDownload(w, r, hcID) case "clone": s.handleHarnessConfigClone(w, r, hcID) + case "reimport": + s.handleHarnessConfigReimport(w, r, hcID) case "files": s.handleHarnessConfigFiles(w, r, hcID, "") default: @@ -408,6 +411,21 @@ func (s *Server) deleteHarnessConfig(w http.ResponseWriter, r *http.Request, id writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) return } + } else if existing.Scope == store.HarnessConfigScopeUser { + userIdent := GetUserIdentityFromContext(ctx) + if userIdent == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) + return + } + if existing.OwnerID != userIdent.ID() { + writeError(w, http.StatusForbidden, ErrCodeForbidden, + "You do not have permission to delete another user's harness config", nil) + return + } + } else { + writeError(w, http.StatusForbidden, ErrCodeForbidden, + "Delete is not supported for this resource scope", nil) + return } if deleteFiles && existing.StoragePath != "" { @@ -695,3 +713,106 @@ func (s *Server) handleHarnessConfigClone(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusCreated, clone) } + +// ReimportHarnessConfigRequest is the optional request body for the reimport endpoint. +type ReimportHarnessConfigRequest struct { + SourceURL string `json:"sourceUrl,omitempty"` +} + +// handleHarnessConfigReimport re-imports a harness-config from its stored +// source_url (or an override URL). POST /api/v1/harness-configs/{id}/reimport +func (s *Server) handleHarnessConfigReimport(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + MethodNotAllowed(w) + return + } + + ctx := r.Context() + + hc, err := s.store.GetHarnessConfig(ctx, id) + if err != nil { + writeErrorFromErr(w, err, "") + return + } + if hc == nil { + NotFound(w, "HarnessConfig") + return + } + + var req ReimportHarnessConfigRequest + if r.Body != nil && r.Body != http.NoBody { + if err := readJSON(r, &req); err != nil { + BadRequest(w, "Invalid request body: "+err.Error()) + return + } + } + + sourceURL := req.SourceURL + if sourceURL == "" { + sourceURL = hc.SourceURL + } + if sourceURL == "" { + writeError(w, http.StatusBadRequest, "no_source_url", + "No source URL stored and none provided. Use the sourceUrl field to specify one.", nil) + return + } + + sourceURL = config.NormalizeTemplateSourceURL(sourceURL) + + // Authorize: same as import — harness_config:create on the owning scope. + if hc.Scope == store.HarnessConfigScopeGlobal { + userIdent := GetUserIdentityFromContext(ctx) + if userIdent == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) + return + } + decision := s.authzService.CheckAccess(ctx, userIdent, Resource{Type: "harness_config"}, ActionCreate) + if !decision.Allowed { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "You do not have permission to reimport global resources", nil) + return + } + } else if hc.Scope == store.HarnessConfigScopeProject { + if !s.authorizeProjectImport(ctx, w, hc.ScopeID, "harness-configs") { + return + } + } else if hc.Scope == store.HarnessConfigScopeUser { + userIdent := GetUserIdentityFromContext(ctx) + if userIdent == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) + return + } + if hc.OwnerID != userIdent.ID() { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "You do not have permission to reimport another user's harness config", nil) + return + } + } else { + writeError(w, http.StatusForbidden, ErrCodeForbidden, "Reimport is not supported for this resource scope", nil) + return + } + + if s.GetStorage() == nil { + writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Storage is not configured", nil) + return + } + + kind := s.harnessConfigImportKind() + run := func(progress importProgressFunc) ([]string, error) { + return s.importFromRemote(ctx, hc.ScopeID, sourceURL, hc.Scope, kind, progress, nil) + } + + if importAcceptsNDJSON(r) { + s.streamImport(w, run) + return + } + + imported, err := run(nil) + if err != nil { + writeError(w, http.StatusBadRequest, "reimport_failed", err.Error(), nil) + return + } + + writeJSON(w, http.StatusOK, ImportHarnessConfigsResponse{ + HarnessConfigs: imported, + Count: len(imported), + }) +} diff --git a/pkg/hub/lifecycle_hook_executor.go b/pkg/hub/lifecycle_hook_executor.go index cb39c0b8e..d3af84305 100644 --- a/pkg/hub/lifecycle_hook_executor.go +++ b/pkg/hub/lifecycle_hook_executor.go @@ -281,6 +281,9 @@ func (e *HTTPExecutor) buildRenderVars(ctx context.Context, hook *store.Lifecycl vars["PROJECT_ID"] = agent.ProjectID if project, err := e.store.GetProject(ctx, agent.ProjectID); err == nil { vars["PROJECT_NAME"] = project.Name + if project.Slug != "" { + vars["PROJECT_SLUG"] = project.Slug + } } } diff --git a/pkg/hub/lifecycle_hook_executor_test.go b/pkg/hub/lifecycle_hook_executor_test.go index 3744406a9..28250cfd7 100644 --- a/pkg/hub/lifecycle_hook_executor_test.go +++ b/pkg/hub/lifecycle_hook_executor_test.go @@ -729,7 +729,17 @@ func TestLifecycleHookExecutor_HTTPRequiresExecutionIdentity(t *testing.T) { func TestLifecycleHookExecutor_RenderVarsCorrectTrustClasses(t *testing.T) { s := executorTestStore(t) - projID := seedExecutorProject(t, s, "test-project") + + // Use distinct Name and Slug to verify the code reads the correct field. + projID := uuid.New().String() + require.NoError(t, s.CreateProject(context.Background(), &store.Project{ + ID: projID, + Name: "Test Project Display Name", + Slug: "test-project-slug", + Visibility: "private", + Created: time.Now(), + Updated: time.Now(), + })) executor := newTestExecutor(s, nil, nil, slog.Default()) agent := makeTestAgent(projID) @@ -754,7 +764,8 @@ func TestLifecycleHookExecutor_RenderVarsCorrectTrustClasses(t *testing.T) { assert.Equal(t, "my-agent", vars["AGENT_SLUG"]) assert.Equal(t, "sa@test.com", vars["SA_EMAIL"]) assert.Equal(t, projID, vars["PROJECT_ID"]) - assert.Equal(t, "test-project", vars["PROJECT_NAME"]) + assert.Equal(t, "Test Project Display Name", vars["PROJECT_NAME"]) + assert.Equal(t, "test-project-slug", vars["PROJECT_SLUG"]) // Verify untrusted variables are present (will be encoded by RenderAction). assert.Equal(t, "Evil Agent", vars["AGENT_NAME"]) diff --git a/pkg/hub/lifecycle_hook_integration_test.go b/pkg/hub/lifecycle_hook_integration_test.go index f08fe4865..a4a378fab 100644 --- a/pkg/hub/lifecycle_hook_integration_test.go +++ b/pkg/hub/lifecycle_hook_integration_test.go @@ -45,10 +45,11 @@ import ( // registryRequest captures a single request received by the mock registry. type registryRequest struct { - Method string - Path string - Body string - Headers http.Header + Method string + Path string + RawQuery string + Body string + Headers http.Header } // mockRegistry is an httptest server that records incoming requests for @@ -65,10 +66,11 @@ func newMockRegistry() *mockRegistry { body, _ := io.ReadAll(req.Body) r.mu.Lock() r.requests = append(r.requests, registryRequest{ - Method: req.Method, - Path: req.URL.Path, - Body: string(body), - Headers: req.Header.Clone(), + Method: req.Method, + Path: req.URL.Path, + RawQuery: req.URL.RawQuery, + Body: string(body), + Headers: req.Header.Clone(), }) r.mu.Unlock() w.WriteHeader(http.StatusOK) @@ -455,3 +457,216 @@ func TestLifecycleHookIntegration_SuspendedAndErrorDeregister(t *testing.T) { assert.True(t, auditEvents[0].Success) assert.True(t, auditEvents[1].Success) } + +// --------------------------------------------------------------------------- +// TestLifecycleHookIntegration_AgentRegistryA2AFlow +// +// End-to-end test of the Google Cloud Agent Registry integration use case: +// 1. Register hook constructs an A2A agent card body with the per-agent +// A2A Bridge endpoint URL built from PROJECT_SLUG and AGENT_SLUG. +// 2. Deregister hook issues a DELETE using the same slug-based service name. +// 3. Validates that PROJECT_SLUG is correctly populated and the agent card +// body contains the specific per-agent A2A Bridge endpoint URL. +// --------------------------------------------------------------------------- + +func TestLifecycleHookIntegration_AgentRegistryA2AFlow(t *testing.T) { + ctx := context.Background() + s := integrationTestStore(t) + + projectSlug := "my-scion-project" + projectID := uuid.New().String() + require.NoError(t, s.CreateProject(ctx, &store.Project{ + ID: projectID, + Name: "My Scion Project", + Slug: projectSlug, + Visibility: "private", + Created: time.Now(), + Updated: time.Now(), + })) + + saID := uuid.New().String() + saEmail := "agent-registry-sa@ptone-emblem.iam.gserviceaccount.com" + require.NoError(t, s.CreateGCPServiceAccount(ctx, &store.GCPServiceAccount{ + ID: saID, + Scope: store.ScopeProject, + ScopeID: projectID, + Email: saEmail, + ProjectID: "ptone-emblem", + DisplayName: "Agent Registry SA", + DefaultScopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + Verified: true, + VerifiedAt: time.Now(), + VerificationStatus: "verified", + CreatedBy: "test-user", + CreatedAt: time.Now(), + })) + + agentSlug := "test-a2a-agent" + agent := &store.Agent{ + ID: uuid.New().String(), + Slug: agentSlug, + Name: "Test A2A Agent", + Template: "claude", + ProjectID: projectID, + Phase: string(state.PhaseStarting), + Visibility: "private", + Created: time.Now(), + Updated: time.Now(), + } + require.NoError(t, s.CreateAgent(ctx, agent)) + + registry := newMockRegistry() + defer registry.close() + + // The hook body mirrors the real Agent Registry API request format. + // It constructs an A2A agent card with the specific per-agent endpoint + // URL from the A2A Bridge (using PROJECT_SLUG and AGENT_SLUG). + a2aBridgeBase := "https://a2a.example.com" + registerBody := `{` + + `"displayName":"Scion Agent: ${PROJECT_SLUG}/${AGENT_SLUG}",` + + `"agentSpec":{` + + `"type":"A2A_AGENT_CARD",` + + `"content":{` + + `"name":"${AGENT_SLUG}",` + + `"description":"Scion agent ${AGENT_SLUG} in project ${PROJECT_SLUG}",` + + `"version":"1.0.0",` + + `"supportedInterfaces":[{"url":"` + a2aBridgeBase + `/projects/${PROJECT_SLUG}/agents/${AGENT_SLUG}","protocolBinding":"JSONRPC","protocolVersion":"0.3"}],` + + `"capabilities":{"streaming":true,"pushNotifications":true},` + + `"defaultInputModes":["text/plain","application/json"],` + + `"defaultOutputModes":["text/plain","application/json"],` + + `"skills":[{"id":"${AGENT_SLUG}","name":"${AGENT_SLUG}","description":"Interact with agent ${AGENT_SLUG}","tags":["scion","a2a"]}],` + + `"provider":{"organization":"Scion","url":"https://github.com/ptone/scion"}` + + `}` + + `}` + + `}` + + registerHook := &store.LifecycleHook{ + ID: uuid.New().String(), + Name: "agent-registry-register", + ScopeType: store.LifecycleHookScopeHub, + Trigger: store.LifecycleHookTriggerRunning, + Action: &store.LifecycleHookAction{ + Type: store.LifecycleHookActionHTTP, + Method: "POST", + URL: registry.server.URL + "/v1alpha/projects/ptone-emblem/locations/us-central1/services?serviceId=scion-${PROJECT_SLUG}-${AGENT_SLUG}", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: registerBody, + OnError: store.LifecycleHookOnErrorRetry, + TimeoutSeconds: 15, + }, + ExecutionIdentity: saID, + Enabled: true, + Created: time.Now(), + Updated: time.Now(), + } + require.NoError(t, s.CreateLifecycleHook(ctx, registerHook)) + + deregisterHook := &store.LifecycleHook{ + ID: uuid.New().String(), + Name: "agent-registry-deregister", + ScopeType: store.LifecycleHookScopeHub, + Trigger: store.LifecycleHookTriggerStopped, + Action: &store.LifecycleHookAction{ + Type: store.LifecycleHookActionHTTP, + Method: "DELETE", + URL: registry.server.URL + "/v1alpha/projects/ptone-emblem/locations/us-central1/services/scion-${PROJECT_SLUG}-${AGENT_SLUG}", + Headers: map[string]string{"Content-Type": "application/json"}, + OnError: store.LifecycleHookOnErrorRetry, + TimeoutSeconds: 15, + }, + ExecutionIdentity: saID, + Enabled: true, + Created: time.Now(), + Updated: time.Now(), + } + require.NoError(t, s.CreateLifecycleHook(ctx, deregisterHook)) + + tokenGen := &mockTokenGenerator{ + accessToken: "agent-registry-bearer-token", + email: "hub-sa@ptone-emblem.iam.gserviceaccount.com", + } + auditLog := newCapturingAuditLogger() + executor := newTestExecutor(s, tokenGen, auditLog, slog.Default()) + + events := NewChannelEventPublisher() + defer events.Close() + + evaluator := NewLifecycleHookEvaluator(s, events, executor, slog.Default()) + evaluator.Start() + defer evaluator.Stop() + + // === Transition to running: should register with Agent Registry === + + agent.Phase = string(state.PhaseRunning) + require.NoError(t, s.UpdateAgentStatus(ctx, agent.ID, store.AgentStatusUpdate{ + Phase: string(state.PhaseRunning), + })) + events.PublishAgentStatus(ctx, agent) + + registry.waitForRequests(t, 1, 5*time.Second) + reqs := registry.getRequests() + require.Len(t, reqs, 1) + + // Verify the POST was sent to the correct Agent Registry path with + // PROJECT_SLUG and AGENT_SLUG substituted in the query parameter. + assert.Equal(t, "POST", reqs[0].Method) + expectedServiceID := fmt.Sprintf("scion-%s-%s", projectSlug, agentSlug) + assert.Equal(t, "/v1alpha/projects/ptone-emblem/locations/us-central1/services", reqs[0].Path) + assert.Equal(t, "serviceId="+expectedServiceID, reqs[0].RawQuery) + + // Verify the body contains a valid A2A agent card with the correct + // per-agent A2A Bridge endpoint URL. + var body map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(reqs[0].Body), &body)) + expectedDisplayName := fmt.Sprintf("Scion Agent: %s/%s", projectSlug, agentSlug) + assert.Equal(t, expectedDisplayName, body["displayName"]) + + agentSpec, ok := body["agentSpec"].(map[string]interface{}) + require.True(t, ok, "agentSpec should be a JSON object") + assert.Equal(t, "A2A_AGENT_CARD", agentSpec["type"]) + + content, ok := agentSpec["content"].(map[string]interface{}) + require.True(t, ok, "agentSpec.content should be a JSON object") + assert.Equal(t, agentSlug, content["name"]) + + interfaces, ok := content["supportedInterfaces"].([]interface{}) + require.True(t, ok, "content.supportedInterfaces should be an array") + require.Len(t, interfaces, 1) + iface, ok := interfaces[0].(map[string]interface{}) + require.True(t, ok) + expectedA2AURL := fmt.Sprintf("%s/projects/%s/agents/%s", a2aBridgeBase, projectSlug, agentSlug) + assert.Equal(t, expectedA2AURL, iface["url"], + "A2A agent card URL should be the specific per-agent A2A Bridge endpoint") + assert.Equal(t, "JSONRPC", iface["protocolBinding"]) + assert.Equal(t, "0.3", iface["protocolVersion"]) + + assert.Equal(t, "Bearer agent-registry-bearer-token", + reqs[0].Headers.Get("Authorization")) + + // === Transition to stopped: should deregister === + + agent.Phase = string(state.PhaseStopped) + require.NoError(t, s.UpdateAgentStatus(ctx, agent.ID, store.AgentStatusUpdate{ + Phase: string(state.PhaseStopped), + })) + events.PublishAgentStatus(ctx, agent) + + registry.waitForRequests(t, 2, 5*time.Second) + reqs = registry.getRequests() + require.Len(t, reqs, 2) + + assert.Equal(t, "DELETE", reqs[1].Method) + expectedDeletePath := fmt.Sprintf("/v1alpha/projects/ptone-emblem/locations/us-central1/services/%s", expectedServiceID) + assert.Equal(t, expectedDeletePath, reqs[1].Path) + assert.Equal(t, "Bearer agent-registry-bearer-token", + reqs[1].Headers.Get("Authorization")) + + // Verify audit trail. + auditEvents := auditLog.getEvents() + require.Len(t, auditEvents, 2) + assert.True(t, auditEvents[0].Success) + assert.Equal(t, "running", auditEvents[0].Trigger) + assert.Equal(t, saEmail, auditEvents[0].ExecutionIdentity) + assert.True(t, auditEvents[1].Success) + assert.Equal(t, "stopped", auditEvents[1].Trigger) +} diff --git a/pkg/hub/maintenance_executors.go b/pkg/hub/maintenance_executors.go index e7fd7fc05..d51f212b7 100644 --- a/pkg/hub/maintenance_executors.go +++ b/pkg/hub/maintenance_executors.go @@ -15,6 +15,7 @@ package hub import ( + "bytes" "context" "encoding/json" "fmt" @@ -29,7 +30,9 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/storage" "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/GoogleCloudPlatform/scion/pkg/transfer" "github.com/GoogleCloudPlatform/scion/pkg/util/logging" + "gopkg.in/yaml.v3" ) // MaintenanceExecutor defines the interface for a runnable maintenance operation. @@ -580,11 +583,90 @@ func (e *BuildHarnessConfigImageExecutor) Run(ctx context.Context, logger io.Wri outputImage = pushImage } + // Update the harness config's image in storage and the DB so agents + // pick up the newly-built image instead of the stale upstream reference. + if err := e.syncBuiltImage(ctx, logger, hc, tmpDir, outputImage); err != nil { + log.Error("Failed to sync built image back to store", "error", err) + fmt.Fprintf(logger, "Warning: build succeeded but failed to update harness-config image: %v\n", err) + } + fmt.Fprintf(logger, "\nBuild complete: %s\n", outputImage) log.Info("Build complete", "image", outputImage, "harness_config", hc.Name) return nil } +// syncBuiltImage updates the harness config's config.yaml in storage and the +// DB record to reference the newly-built image. +func (e *BuildHarnessConfigImageExecutor) syncBuiltImage(ctx context.Context, logger io.Writer, hc *store.HarnessConfig, tmpDir, outputImage string) error { + configPath := filepath.Join(tmpDir, "config.yaml") + configData, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config.yaml: %w", err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(configData, &doc); err != nil { + return fmt.Errorf("failed to parse config.yaml: %w", err) + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("config.yaml root is not a YAML mapping") + } + { + mapping := doc.Content[0] + found := false + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == "image" { + mapping.Content[i+1].Value = outputImage + found = true + break + } + } + if !found { + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "image"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: outputImage}, + ) + } + } + + updatedData, err := yaml.Marshal(&doc) + if err != nil { + return fmt.Errorf("failed to marshal updated config.yaml: %w", err) + } + + // Upload updated config.yaml to storage. + if e.storage != nil && hc.StoragePath != "" { + objectPath := hc.StoragePath + "/config.yaml" + if _, err := e.storage.Upload(ctx, objectPath, bytes.NewReader(updatedData), storage.UploadOptions{}); err != nil { + return fmt.Errorf("failed to upload updated config.yaml to storage: %w", err) + } + fmt.Fprintf(logger, "Updated config.yaml in storage with image %s\n", outputImage) + } + + // Update config.yaml entry in hc.Files manifest with new size and hash. + configHash := transfer.HashBytes(updatedData) + for i, f := range hc.Files { + if f.Path == "config.yaml" { + hc.Files[i].Size = int64(len(updatedData)) + hc.Files[i].Hash = configHash + break + } + } + hc.ContentHash = computeContentHash(hc.Files) + + // Update the DB record. + if hc.Config == nil { + hc.Config = &store.HarnessConfigData{} + } + hc.Config.Image = outputImage + if err := e.store.UpdateHarnessConfig(ctx, hc); err != nil { + return fmt.Errorf("failed to update harness-config record: %w", err) + } + fmt.Fprintf(logger, "Updated harness-config record image to %s\n", outputImage) + + return nil +} + // UpdateCheckResult contains the result of a check-for-updates operation. type UpdateCheckResult struct { UpdateAvailable bool `json:"update_available"` diff --git a/pkg/hub/metrics_dashboard.go b/pkg/hub/metrics_dashboard.go new file mode 100644 index 000000000..e6c9d5323 --- /dev/null +++ b/pkg/hub/metrics_dashboard.go @@ -0,0 +1,893 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hub + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + monitoring "cloud.google.com/go/monitoring/apiv3/v2" + "cloud.google.com/go/monitoring/apiv3/v2/monitoringpb" + "github.com/GoogleCloudPlatform/scion/pkg/store" + "google.golang.org/api/iterator" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + metricPrefix = "workload.googleapis.com/" + cacheTTL = 5 * time.Minute + maxPeriodDays = 90 + defaultPeriod = 7 + alignmentDay = 86400 // seconds in a day + alignmentHour = 3600 +) + +// MetricsDashboardService queries Google Cloud Monitoring for Scion telemetry metrics. +type MetricsDashboardService struct { + client *monitoring.MetricClient + projectID string + + mu sync.RWMutex + cache map[string]*cacheEntry +} + +type cacheEntry struct { + data interface{} + fetchedAt time.Time +} + +// NewMetricsDashboardService creates a new service for querying Cloud Monitoring. +func NewMetricsDashboardService(ctx context.Context, projectID string) (*MetricsDashboardService, error) { + if projectID == "" { + return nil, fmt.Errorf("GCP project ID is required for metrics dashboard") + } + + client, err := monitoring.NewMetricClient(ctx) + if err != nil { + return nil, fmt.Errorf("creating monitoring client: %w", err) + } + + return &MetricsDashboardService{ + client: client, + projectID: projectID, + cache: make(map[string]*cacheEntry), + }, nil +} + +// Close releases resources. +func (s *MetricsDashboardService) Close() error { + return s.client.Close() +} + +// DashboardSummary contains aggregate metric counts for a period. +type DashboardSummary struct { + PeriodDays int `json:"periodDays"` + TotalSessions int64 `json:"totalSessions"` + TotalAPICalls int64 `json:"totalApiCalls"` + TotalTokens int64 `json:"totalTokens"` + UniqueAgents int `json:"uniqueAgents"` +} + +// TimeSeriesPoint represents a single data point in a time series. +type TimeSeriesPoint struct { + Timestamp string `json:"timestamp"` + Value int64 `json:"value"` +} + +// LabeledTimeSeries groups time series data by a label value. +type LabeledTimeSeries struct { + Label string `json:"label"` + Points []TimeSeriesPoint `json:"points"` +} + +// SessionsView contains session count and active agent data. +type SessionsView struct { + PeriodDays int `json:"periodDays"` + DailyCounts []TimeSeriesPoint `json:"dailyCounts"` + ActiveAgents []TimeSeriesPoint `json:"activeAgents"` +} + +// ModelCallsView contains API call data grouped by model and harness. +type ModelCallsView struct { + PeriodDays int `json:"periodDays"` + ByModel []LabeledTimeSeries `json:"byModel"` + ByHarness []LabeledTimeSeries `json:"byHarness"` +} + +// TokensView contains token usage data grouped by model. +type TokensView struct { + PeriodDays int `json:"periodDays"` + Input []LabeledTimeSeries `json:"input"` + Output []LabeledTimeSeries `json:"output"` +} + +func (s *MetricsDashboardService) getCached(key string) (interface{}, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + entry, ok := s.cache[key] + if !ok || time.Since(entry.fetchedAt) > cacheTTL { + return nil, false + } + return entry.data, true +} + +func (s *MetricsDashboardService) setCache(key string, data interface{}) { + s.mu.Lock() + defer s.mu.Unlock() + s.cache[key] = &cacheEntry{data: data, fetchedAt: time.Now()} +} + +// QueryOption configures optional query parameters. +type QueryOption func(*queryConfig) + +type queryConfig struct { + ProjectID string +} + +// WithProjectID filters metrics to a specific project. +func WithProjectID(id string) QueryOption { + return func(c *queryConfig) { c.ProjectID = id } +} + +func applyQueryOptions(opts []QueryOption) *queryConfig { + cfg := &queryConfig{} + for _, o := range opts { + o(cfg) + } + return cfg +} + +// cacheKeySuffix returns a cache key suffix for the query config. +// Returns empty string for global queries, ":projectID" for project-scoped. +func (c *queryConfig) cacheKeySuffix() string { + if c.ProjectID != "" { + return ":" + c.ProjectID + } + return "" +} + +// projectFilter returns the Cloud Monitoring filter clause for a project ID. +func projectFilter(projectID string) string { + return fmt.Sprintf(`metric.labels.project_id = "%s"`, projectID) +} + +// QuerySummary returns aggregate metric counts for the given period. +func (s *MetricsDashboardService) QuerySummary(ctx context.Context, periodDays int, opts ...QueryOption) (*DashboardSummary, error) { + cfg := applyQueryOptions(opts) + cacheKey := fmt.Sprintf("summary:%d%s", periodDays, cfg.cacheKeySuffix()) + if cached, ok := s.getCached(cacheKey); ok { + return cached.(*DashboardSummary), nil + } + + now := time.Now().UTC() + start := now.AddDate(0, 0, -periodDays) + + var extraFilter []string + if cfg.ProjectID != "" { + extraFilter = append(extraFilter, projectFilter(cfg.ProjectID)) + } + + summary := &DashboardSummary{PeriodDays: periodDays} + var queryErrors []string + + sessions, err := s.querySum(ctx, "agent.session.count", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("session count: %v", err)) + } else { + summary.TotalSessions = sessions + } + + apiCalls, err := s.querySum(ctx, "gen_ai.api.calls", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("API calls: %v", err)) + } else { + summary.TotalAPICalls = apiCalls + } + + inputTokens, err := s.querySum(ctx, "gen_ai.tokens.input", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("input tokens: %v", err)) + } + outputTokens, err := s.querySum(ctx, "gen_ai.tokens.output", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("output tokens: %v", err)) + } + summary.TotalTokens = inputTokens + outputTokens + + agents, err := s.queryUniqueLabels(ctx, "agent.session.count", "metric.labels.agent_id", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("unique agents: %v", err)) + } else { + summary.UniqueAgents = len(agents) + } + + if len(queryErrors) > 0 { + return summary, fmt.Errorf("partial query failures: %s", strings.Join(queryErrors, "; ")) + } + + s.setCache(cacheKey, summary) + return summary, nil +} + +// QuerySessions returns daily session counts and active agent counts. +func (s *MetricsDashboardService) QuerySessions(ctx context.Context, periodDays int, opts ...QueryOption) (*SessionsView, error) { + cfg := applyQueryOptions(opts) + cacheKey := fmt.Sprintf("sessions:%d%s", periodDays, cfg.cacheKeySuffix()) + if cached, ok := s.getCached(cacheKey); ok { + return cached.(*SessionsView), nil + } + + now := time.Now().UTC() + start := now.AddDate(0, 0, -periodDays) + + var extraFilter []string + if cfg.ProjectID != "" { + extraFilter = append(extraFilter, projectFilter(cfg.ProjectID)) + } + + view := &SessionsView{PeriodDays: periodDays} + var queryErrors []string + + dailyCounts, err := s.queryDailyTimeSeries(ctx, "agent.session.count", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("daily sessions: %v", err)) + } else { + view.DailyCounts = dailyCounts + } + + activeAgents, err := s.queryDailyUniqueCount(ctx, "agent.session.count", "metric.labels.agent_id", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("active agents: %v", err)) + } else { + view.ActiveAgents = activeAgents + } + + if len(queryErrors) > 0 { + return view, fmt.Errorf("partial query failures: %s", strings.Join(queryErrors, "; ")) + } + + s.setCache(cacheKey, view) + return view, nil +} + +// QueryModelCalls returns API call data grouped by model and harness. +func (s *MetricsDashboardService) QueryModelCalls(ctx context.Context, periodDays int, opts ...QueryOption) (*ModelCallsView, error) { + cfg := applyQueryOptions(opts) + cacheKey := fmt.Sprintf("model-calls:%d%s", periodDays, cfg.cacheKeySuffix()) + if cached, ok := s.getCached(cacheKey); ok { + return cached.(*ModelCallsView), nil + } + + now := time.Now().UTC() + start := now.AddDate(0, 0, -periodDays) + + var extraFilter []string + if cfg.ProjectID != "" { + extraFilter = append(extraFilter, projectFilter(cfg.ProjectID)) + } + + view := &ModelCallsView{PeriodDays: periodDays} + var queryErrors []string + + byModel, err := s.queryGroupedTimeSeries(ctx, "gen_ai.api.calls", "metric.labels.model", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("by model: %v", err)) + } else { + view.ByModel = byModel + } + + byHarness, err := s.queryGroupedTimeSeries(ctx, "gen_ai.api.calls", "metric.labels.harness", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("by harness: %v", err)) + } else { + view.ByHarness = byHarness + } + + if len(queryErrors) > 0 { + return view, fmt.Errorf("partial query failures: %s", strings.Join(queryErrors, "; ")) + } + + s.setCache(cacheKey, view) + return view, nil +} + +// QueryTokens returns token usage data grouped by model. +func (s *MetricsDashboardService) QueryTokens(ctx context.Context, periodDays int, opts ...QueryOption) (*TokensView, error) { + cfg := applyQueryOptions(opts) + cacheKey := fmt.Sprintf("tokens:%d%s", periodDays, cfg.cacheKeySuffix()) + if cached, ok := s.getCached(cacheKey); ok { + return cached.(*TokensView), nil + } + + now := time.Now().UTC() + start := now.AddDate(0, 0, -periodDays) + + var extraFilter []string + if cfg.ProjectID != "" { + extraFilter = append(extraFilter, projectFilter(cfg.ProjectID)) + } + + view := &TokensView{PeriodDays: periodDays} + var queryErrors []string + + input, err := s.queryGroupedTimeSeries(ctx, "gen_ai.tokens.input", "metric.labels.model", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("input tokens: %v", err)) + } else { + view.Input = input + } + + output, err := s.queryGroupedTimeSeries(ctx, "gen_ai.tokens.output", "metric.labels.model", start, now, extraFilter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("output tokens: %v", err)) + } else { + view.Output = output + } + + if len(queryErrors) > 0 { + return view, fmt.Errorf("partial query failures: %s", strings.Join(queryErrors, "; ")) + } + + s.setCache(cacheKey, view) + return view, nil +} + +// querySum queries a metric and returns the total sum across all time series and points. +func (s *MetricsDashboardService) querySum(ctx context.Context, metricName string, start, end time.Time, extraFilter []string) (int64, error) { + filter := fmt.Sprintf(`metric.type = "%s%s"`, metricPrefix, metricName) + for _, f := range extraFilter { + filter += " AND " + f + } + + req := &monitoringpb.ListTimeSeriesRequest{ + Name: fmt.Sprintf("projects/%s", s.projectID), + Filter: filter, + Interval: &monitoringpb.TimeInterval{ + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(end), + }, + Aggregation: &monitoringpb.Aggregation{ + AlignmentPeriod: durationpb.New(end.Sub(start).Truncate(time.Second)), + PerSeriesAligner: monitoringpb.Aggregation_ALIGN_DELTA, + CrossSeriesReducer: monitoringpb.Aggregation_REDUCE_SUM, + }, + } + + var total int64 + it := s.client.ListTimeSeries(ctx, req) + for { + ts, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return 0, fmt.Errorf("listing time series for %s: %w", metricName, err) + } + for _, p := range ts.GetPoints() { + total += p.GetValue().GetInt64Value() + } + } + return total, nil +} + +// queryDailyTimeSeries returns daily aggregated data points for a metric. +func (s *MetricsDashboardService) queryDailyTimeSeries(ctx context.Context, metricName string, start, end time.Time, extraFilter []string) ([]TimeSeriesPoint, error) { + filter := fmt.Sprintf(`metric.type = "%s%s"`, metricPrefix, metricName) + for _, f := range extraFilter { + filter += " AND " + f + } + + req := &monitoringpb.ListTimeSeriesRequest{ + Name: fmt.Sprintf("projects/%s", s.projectID), + Filter: filter, + Interval: &monitoringpb.TimeInterval{ + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(end), + }, + Aggregation: &monitoringpb.Aggregation{ + AlignmentPeriod: durationpb.New(time.Duration(alignmentDay) * time.Second), + PerSeriesAligner: monitoringpb.Aggregation_ALIGN_DELTA, + CrossSeriesReducer: monitoringpb.Aggregation_REDUCE_SUM, + }, + } + + var points []TimeSeriesPoint + it := s.client.ListTimeSeries(ctx, req) + for { + ts, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("listing daily time series for %s: %w", metricName, err) + } + for _, p := range ts.GetPoints() { + points = append(points, TimeSeriesPoint{ + Timestamp: p.GetInterval().GetEndTime().AsTime().Format("2006-01-02"), + Value: p.GetValue().GetInt64Value(), + }) + } + } + return points, nil +} + +// labelKeyFromGroupBy extracts the short label key from a Cloud Monitoring +// groupByLabel like "metric.labels.model" → "model". +func labelKeyFromGroupBy(groupByLabel string) string { + parts := strings.Split(groupByLabel, ".") + return parts[len(parts)-1] +} + +// queryGroupedTimeSeries returns daily data grouped by a label. +func (s *MetricsDashboardService) queryGroupedTimeSeries(ctx context.Context, metricName, groupByLabel string, start, end time.Time, extraFilter []string) ([]LabeledTimeSeries, error) { + filter := fmt.Sprintf(`metric.type = "%s%s"`, metricPrefix, metricName) + for _, f := range extraFilter { + filter += " AND " + f + } + labelKey := labelKeyFromGroupBy(groupByLabel) + + req := &monitoringpb.ListTimeSeriesRequest{ + Name: fmt.Sprintf("projects/%s", s.projectID), + Filter: filter, + Interval: &monitoringpb.TimeInterval{ + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(end), + }, + Aggregation: &monitoringpb.Aggregation{ + AlignmentPeriod: durationpb.New(time.Duration(alignmentDay) * time.Second), + PerSeriesAligner: monitoringpb.Aggregation_ALIGN_DELTA, + CrossSeriesReducer: monitoringpb.Aggregation_REDUCE_SUM, + GroupByFields: []string{groupByLabel}, + }, + } + + seriesMap := make(map[string][]TimeSeriesPoint) + it := s.client.ListTimeSeries(ctx, req) + for { + ts, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("listing grouped time series for %s: %w", metricName, err) + } + + label := "(unknown)" + if labels := ts.GetMetric().GetLabels(); labels != nil { + if v, ok := labels[labelKey]; ok && v != "" { + label = v + } + } + + for _, p := range ts.GetPoints() { + seriesMap[label] = append(seriesMap[label], TimeSeriesPoint{ + Timestamp: p.GetInterval().GetEndTime().AsTime().Format("2006-01-02"), + Value: p.GetValue().GetInt64Value(), + }) + } + } + + var result []LabeledTimeSeries + for label, points := range seriesMap { + result = append(result, LabeledTimeSeries{Label: label, Points: points}) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Label < result[j].Label + }) + return result, nil +} + +// queryUniqueLabels returns unique values for a label within a metric's time series. +func (s *MetricsDashboardService) queryUniqueLabels(ctx context.Context, metricName, groupByLabel string, start, end time.Time, extraFilter []string) (map[string]bool, error) { + filter := fmt.Sprintf(`metric.type = "%s%s"`, metricPrefix, metricName) + for _, f := range extraFilter { + filter += " AND " + f + } + labelKey := labelKeyFromGroupBy(groupByLabel) + + req := &monitoringpb.ListTimeSeriesRequest{ + Name: fmt.Sprintf("projects/%s", s.projectID), + Filter: filter, + Interval: &monitoringpb.TimeInterval{ + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(end), + }, + Aggregation: &monitoringpb.Aggregation{ + AlignmentPeriod: durationpb.New(end.Sub(start).Truncate(time.Second)), + PerSeriesAligner: monitoringpb.Aggregation_ALIGN_DELTA, + CrossSeriesReducer: monitoringpb.Aggregation_REDUCE_SUM, + GroupByFields: []string{groupByLabel}, + }, + } + + unique := make(map[string]bool) + it := s.client.ListTimeSeries(ctx, req) + for { + ts, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("listing unique labels for %s: %w", metricName, err) + } + if labels := ts.GetMetric().GetLabels(); labels != nil { + if v, ok := labels[labelKey]; ok && v != "" { + unique[v] = true + } + } + } + return unique, nil +} + +// queryDailyUniqueCount returns per-day counts of unique label values. +func (s *MetricsDashboardService) queryDailyUniqueCount(ctx context.Context, metricName, groupByLabel string, start, end time.Time, extraFilter []string) ([]TimeSeriesPoint, error) { + filter := fmt.Sprintf(`metric.type = "%s%s"`, metricPrefix, metricName) + for _, f := range extraFilter { + filter += " AND " + f + } + labelKey := labelKeyFromGroupBy(groupByLabel) + + req := &monitoringpb.ListTimeSeriesRequest{ + Name: fmt.Sprintf("projects/%s", s.projectID), + Filter: filter, + Interval: &monitoringpb.TimeInterval{ + StartTime: timestamppb.New(start), + EndTime: timestamppb.New(end), + }, + Aggregation: &monitoringpb.Aggregation{ + AlignmentPeriod: durationpb.New(time.Duration(alignmentDay) * time.Second), + PerSeriesAligner: monitoringpb.Aggregation_ALIGN_DELTA, + CrossSeriesReducer: monitoringpb.Aggregation_REDUCE_SUM, + GroupByFields: []string{groupByLabel}, + }, + } + + // Count unique label values per day + dayAgents := make(map[string]map[string]bool) // date -> set of label values + it := s.client.ListTimeSeries(ctx, req) + for { + ts, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("listing daily unique for %s: %w", metricName, err) + } + + label := "(unknown)" + if labels := ts.GetMetric().GetLabels(); labels != nil { + if v, ok := labels[labelKey]; ok && v != "" { + label = v + } + } + + for _, p := range ts.GetPoints() { + day := p.GetInterval().GetEndTime().AsTime().Format("2006-01-02") + if dayAgents[day] == nil { + dayAgents[day] = make(map[string]bool) + } + if p.GetValue().GetInt64Value() > 0 { + dayAgents[day][label] = true + } + } + } + + var points []TimeSeriesPoint + for day, agents := range dayAgents { + points = append(points, TimeSeriesPoint{ + Timestamp: day, + Value: int64(len(agents)), + }) + } + sort.Slice(points, func(i, j int) bool { + return points[i].Timestamp < points[j].Timestamp + }) + return points, nil +} + +// ProjectMetricsSummary contains lightweight scalar metrics for a project's status bar. +type ProjectMetricsSummary struct { + SessionsCount24h int64 `json:"sessionsCount24h"` + APICalls24h int64 `json:"apiCalls24h"` + TokenUsage24h int64 `json:"tokenUsage24h"` + ActiveAgents24h int `json:"activeAgents24h"` + PeriodLabel string `json:"periodLabel"` +} + +// QueryProjectSummary returns lightweight scalar metrics for a project over the last 24 hours. +func (s *MetricsDashboardService) QueryProjectSummary(ctx context.Context, projectID string) (*ProjectMetricsSummary, error) { + cacheKey := fmt.Sprintf("project-summary:%s", projectID) + if cached, ok := s.getCached(cacheKey); ok { + return cached.(*ProjectMetricsSummary), nil + } + + now := time.Now().UTC() + start := now.AddDate(0, 0, -1) // 24 hours + + filter := []string{projectFilter(projectID)} + + summary := &ProjectMetricsSummary{PeriodLabel: "Last 24 hours"} + var queryErrors []string + + sessions, err := s.querySum(ctx, "agent.session.count", start, now, filter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("sessions: %v", err)) + } else { + summary.SessionsCount24h = sessions + } + + apiCalls, err := s.querySum(ctx, "gen_ai.api.calls", start, now, filter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("API calls: %v", err)) + } else { + summary.APICalls24h = apiCalls + } + + inputTokens, err := s.querySum(ctx, "gen_ai.tokens.input", start, now, filter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("input tokens: %v", err)) + } + outputTokens, err := s.querySum(ctx, "gen_ai.tokens.output", start, now, filter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("output tokens: %v", err)) + } + summary.TokenUsage24h = inputTokens + outputTokens + + agents, err := s.queryUniqueLabels(ctx, "agent.session.count", "metric.labels.agent_id", start, now, filter) + if err != nil { + queryErrors = append(queryErrors, fmt.Sprintf("active agents: %v", err)) + } else { + summary.ActiveAgents24h = len(agents) + } + + if len(queryErrors) > 0 { + return summary, fmt.Errorf("partial query failures: %s", strings.Join(queryErrors, "; ")) + } + + s.setCache(cacheKey, summary) + return summary, nil +} + +// handleProjectMetricsSummary returns lightweight metrics summary for a project. +func (s *Server) handleProjectMetricsSummary(w http.ResponseWriter, r *http.Request, projectID string) { + ctx := r.Context() + + if r.Method != http.MethodGet { + MethodNotAllowed(w) + return + } + + // Verify project exists + project, err := s.store.GetProject(ctx, projectID) + if err != nil { + if err == store.ErrNotFound { + NotFound(w, "Project") + return + } + writeErrorFromErr(w, err, "") + return + } + + // Authorize: any authenticated user with view access + identity := GetIdentityFromContext(ctx) + if identity == nil { + Unauthorized(w) + return + } + + if userIdent, ok := identity.(UserIdentity); ok { + decision := s.authzService.CheckAccess(ctx, userIdent, Resource{ + Type: "project", + ID: project.ID, + OwnerID: project.OwnerID, + }, ActionRead) + if !decision.Allowed { + Forbidden(w) + return + } + } else if agentIdent, ok := identity.(AgentIdentity); ok { + if agentIdent.ProjectID() != projectID { + Forbidden(w) + return + } + } else { + Forbidden(w) + return + } + + // If metrics service is not configured, return unavailable indicator + if s.metricsDashboard == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"available": false}) + return + } + + data, err := s.metricsDashboard.QueryProjectSummary(ctx, projectID) + if err != nil { + if data == nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, + "Failed to query project metrics summary", nil) + return + } + slog.Warn("Partial project metrics summary failure", "projectID", projectID, "error", err) + } + writeJSON(w, http.StatusOK, data) +} + +// handleMetricsDashboard serves the metrics dashboard API to any authenticated user. +func (s *Server) handleMetricsDashboard(w http.ResponseWriter, r *http.Request) { + identity := GetUserIdentityFromContext(r.Context()) + if identity == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) + return + } + + s.serveMetricsDashboard(w, r) +} + +// handleAdminMetricsDashboard serves the metrics dashboard API (legacy admin-scoped path). +// Kept for backward compatibility — delegates to the same handler with relaxed auth. +// NOTE: This endpoint intentionally no longer requires admin role. The metrics dashboard +// was moved from admin-only to all-authenticated-users access as part of the metrics +// dashboard refactoring. The old admin-scoped URL is maintained for browser bookmark +// backward compatibility and will be removed in a future release. +func (s *Server) handleAdminMetricsDashboard(w http.ResponseWriter, r *http.Request) { + identity := GetUserIdentityFromContext(r.Context()) + if identity == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil) + return + } + + s.serveMetricsDashboard(w, r) +} + +// handleProjectMetricsDashboard serves the per-project metrics dashboard. +func (s *Server) handleProjectMetricsDashboard(w http.ResponseWriter, r *http.Request, projectID, _ string) { + ctx := r.Context() + + // Verify project exists + project, err := s.store.GetProject(ctx, projectID) + if err != nil { + if err == store.ErrNotFound { + NotFound(w, "Project") + return + } + writeErrorFromErr(w, err, "") + return + } + + // Authorize: any authenticated user with view access to the project + identity := GetIdentityFromContext(ctx) + if identity == nil { + Unauthorized(w) + return + } + + if userIdent, ok := identity.(UserIdentity); ok { + decision := s.authzService.CheckAccess(ctx, userIdent, Resource{ + Type: "project", + ID: project.ID, + OwnerID: project.OwnerID, + }, ActionRead) + if !decision.Allowed { + Forbidden(w) + return + } + } else if agentIdent, ok := identity.(AgentIdentity); ok { + if agentIdent.ProjectID() != projectID { + Forbidden(w) + return + } + } else { + Forbidden(w) + return + } + + s.serveMetricsDashboard(w, r, WithProjectID(projectID)) +} + +// serveMetricsDashboard contains the shared metrics dashboard logic. +func (s *Server) serveMetricsDashboard(w http.ResponseWriter, r *http.Request, opts ...QueryOption) { + if r.Method != http.MethodGet { + MethodNotAllowed(w) + return + } + + if s.metricsDashboard == nil { + writeError(w, http.StatusServiceUnavailable, "metrics_unavailable", + "Metrics dashboard is not configured (no telemetry project ID)", nil) + return + } + + view := r.URL.Query().Get("view") + if view == "" { + view = "summary" + } + + periodStr := r.URL.Query().Get("period") + periodDays := defaultPeriod + if periodStr != "" { + if p, err := strconv.Atoi(periodStr); err == nil && p > 0 && p <= maxPeriodDays { + periodDays = p + } + } + + ctx := r.Context() + + switch view { + case "summary": + data, err := s.metricsDashboard.QuerySummary(ctx, periodDays, opts...) + if err != nil { + if data == nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, + "Failed to query metrics summary", nil) + return + } + slog.Warn("Partial metrics query failure", "view", view, "error", err) + + } + writeJSON(w, http.StatusOK, data) + + case "sessions": + data, err := s.metricsDashboard.QuerySessions(ctx, periodDays, opts...) + if err != nil { + if data == nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, + "Failed to query session metrics", nil) + return + } + slog.Warn("Partial metrics query failure", "view", view, "error", err) + + } + writeJSON(w, http.StatusOK, data) + + case "model-calls": + data, err := s.metricsDashboard.QueryModelCalls(ctx, periodDays, opts...) + if err != nil { + if data == nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, + "Failed to query model call metrics", nil) + return + } + slog.Warn("Partial metrics query failure", "view", view, "error", err) + + } + writeJSON(w, http.StatusOK, data) + + case "tokens": + data, err := s.metricsDashboard.QueryTokens(ctx, periodDays, opts...) + if err != nil { + if data == nil { + writeError(w, http.StatusInternalServerError, ErrCodeInternalError, + "Failed to query token metrics", nil) + return + } + slog.Warn("Partial metrics query failure", "view", view, "error", err) + + } + writeJSON(w, http.StatusOK, data) + + default: + writeError(w, http.StatusBadRequest, "invalid_view", + fmt.Sprintf("Unknown view: %s. Valid views: summary, sessions, model-calls, tokens", view), nil) + } +} diff --git a/pkg/hub/resource_import.go b/pkg/hub/resource_import.go index e9e48aef3..2511c39cd 100644 --- a/pkg/hub/resource_import.go +++ b/pkg/hub/resource_import.go @@ -51,7 +51,7 @@ const resourceImportConcurrency = 6 // naming, the create-or-sync loop — is shared here. // resourceDir pairs a derived resource name with its on-disk directory. -type resourceDir struct{ name, path string } +type resourceDir struct{ name, path, sourceURL string } // skippedDir pairs a child directory name with the reason it was skipped during // discovery (e.g. it lacks the kind's marker file, so it is not a resource). @@ -314,7 +314,12 @@ func discoverResourceDirs(root, sourceURL string, kind resourceImportKind) ([]re name = derived } } - return []resourceDir{{name, root}}, nil, nil + if kind.marker == "config.yaml" { + if hcDir, err := config.LoadHarnessConfigDir(root); err == nil && hcDir.Config.Name != "" { + name = hcDir.Config.Name + } + } + return []resourceDir{{name, root, sourceURL}}, nil, nil } entries, err := os.ReadDir(root) @@ -335,7 +340,13 @@ func discoverResourceDirs(root, sourceURL string, kind resourceImportKind) ([]re } dir := filepath.Join(root, entry.Name()) if kind.isResourceDir(dir) { - dirs = append(dirs, resourceDir{entry.Name(), dir}) + childName := entry.Name() + if kind.marker == "config.yaml" { + if hcDir, err := config.LoadHarnessConfigDir(dir); err == nil && hcDir.Config.Name != "" { + childName = hcDir.Config.Name + } + } + dirs = append(dirs, resourceDir{childName, dir, sourceURL}) } else { skipped = append(skipped, skippedDir{entry.Name(), "no " + kind.marker}) } @@ -405,7 +416,7 @@ func (s *Server) importResourceDirs(ctx context.Context, dirs []resourceDir, ski rstore, err := kind.newStore(rd.path) if err == nil { - _, err = rstore.Bootstrap(ctx, rd.name, rd.path, scope, scopeID, true) + _, err = rstore.Bootstrap(ctx, rd.name, rd.path, scope, scopeID, rd.sourceURL, true) } done := int(completed.Add(1)) if err != nil { diff --git a/pkg/hub/resource_store.go b/pkg/hub/resource_store.go index 01bc71054..79836c372 100644 --- a/pkg/hub/resource_store.go +++ b/pkg/hub/resource_store.go @@ -54,6 +54,7 @@ type ResourceRecord struct { StoragePath string Files []store.TemplateFile Status string + SourceURL string Visibility string } @@ -118,7 +119,7 @@ func (s *Server) harnessConfigStore(harness string) *ResourceStore { // storage backend + DB. When force is true it always re-uploads and reconciles // stale objects; when false it short-circuits if the aggregate content hash // already matches what is stored. Returns whether the stored content changed. -func (rs *ResourceStore) Bootstrap(ctx context.Context, name, dir, scope, scopeID string, force bool) (bool, error) { +func (rs *ResourceStore) Bootstrap(ctx context.Context, name, dir, scope, scopeID, sourceURL string, force bool) (bool, error) { srv := rs.srv p := rs.pers kind := p.Kind() @@ -152,6 +153,7 @@ func (rs *ResourceStore) Bootstrap(ctx context.Context, name, dir, scope, scopeI StoragePath: storagePath, StorageBucket: stor.Bucket(), StorageURI: storage.ResourceStorageURI(stor.Bucket(), kind, scope, scopeID, slug), + SourceURL: sourceURL, Visibility: p.DefaultVisibility(), } if err := p.Create(ctx, rec, dir); err != nil { @@ -206,6 +208,9 @@ func (rs *ResourceStore) Bootstrap(ctx context.Context, name, dir, scope, scopeI existing.Files = uploaded existing.ContentHash = newHash + if sourceURL != "" { + existing.SourceURL = sourceURL + } // Activate the record now that the upload succeeded. This also recovers a // record left in "pending" by a prior bootstrap that failed mid-upload: the // retry re-syncs and flips it to active rather than leaving it stuck. @@ -253,6 +258,7 @@ func (p *templatePersistence) Create(ctx context.Context, rec *ResourceRecord, d StoragePath: rec.StoragePath, StorageBucket: rec.StorageBucket, StorageURI: rec.StorageURI, + SourceURL: rec.SourceURL, Visibility: rec.Visibility, } p.applyDirMeta(t, dir, rec) @@ -265,6 +271,9 @@ func (p *templatePersistence) Update(ctx context.Context, rec *ResourceRecord, d t.Files = rec.Files t.ContentHash = rec.ContentHash t.Status = rec.Status + if rec.SourceURL != "" { + t.SourceURL = rec.SourceURL + } p.applyDirMeta(t, dir, rec) return p.s.store.UpdateTemplate(ctx, t) } @@ -322,6 +331,7 @@ func templateToRecord(t *store.Template) *ResourceRecord { StoragePath: t.StoragePath, Files: t.Files, Status: t.Status, + SourceURL: t.SourceURL, Visibility: t.Visibility, } } @@ -364,6 +374,7 @@ func (p *harnessConfigPersistence) Create(ctx context.Context, rec *ResourceReco StoragePath: rec.StoragePath, StorageBucket: rec.StorageBucket, StorageURI: rec.StorageURI, + SourceURL: rec.SourceURL, Visibility: rec.Visibility, } rec.Harness = p.harness @@ -378,6 +389,9 @@ func (p *harnessConfigPersistence) Update(ctx context.Context, rec *ResourceReco hc.Status = rec.Status hc.Harness = p.harness rec.Harness = p.harness + if rec.SourceURL != "" { + hc.SourceURL = rec.SourceURL + } return p.s.store.UpdateHarnessConfig(ctx, hc) } @@ -406,6 +420,7 @@ func harnessConfigToRecord(hc *store.HarnessConfig) *ResourceRecord { StoragePath: hc.StoragePath, Files: hc.Files, Status: hc.Status, + SourceURL: hc.SourceURL, Visibility: hc.Visibility, } } diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 19b2d2a8f..8bf5fd4ab 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -164,6 +164,10 @@ type ServerConfig struct { // GCPProjectID is the GCP project ID used for minting service accounts. // If empty, auto-detected from the metadata server when running on GCE/Cloud Run. GCPProjectID string + // TelemetryProjectID is the GCP project where telemetry metrics are stored. + // Used by the metrics dashboard to query Cloud Monitoring. + // Falls back to GCPProjectID if empty. + TelemetryProjectID string // GCPMintCapPerProject is the maximum number of minted service accounts allowed per project. // Zero means unlimited (default). GCPMintCapPerProject int @@ -588,7 +592,8 @@ type Server struct { ctx context.Context // Server-lifetime context; cancelled on Shutdown ctxCancel context.CancelFunc // Cancels ctx - logQueryService *LogQueryService // Cloud Logging query service (nil = disabled) + logQueryService *LogQueryService // Cloud Logging query service (nil = disabled) + metricsDashboard *MetricsDashboardService // Cloud Monitoring metrics dashboard (nil = disabled) // Telegram link service for code-based account linking (nil = disabled) telegramLinkService *TelegramLinkService @@ -936,6 +941,25 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { } } + // Initialize metrics dashboard service (optional, gated on telemetry project ID) + if telemetryProject := cfg.TelemetryProjectID; telemetryProject != "" { + metricsSvc, err := NewMetricsDashboardService(ctx, telemetryProject) + if err != nil { + slog.Warn("Failed to initialize metrics dashboard service", "error", err) + } else { + srv.metricsDashboard = metricsSvc + slog.Info("Metrics dashboard service initialized", "project", telemetryProject) + } + } else if projectID := cfg.GCPProjectID; projectID != "" { + metricsSvc, err := NewMetricsDashboardService(ctx, projectID) + if err != nil { + slog.Warn("Failed to initialize metrics dashboard service", "error", err) + } else { + srv.metricsDashboard = metricsSvc + slog.Info("Metrics dashboard service initialized (from GCPProjectID)", "project", projectID) + } + } + // Initialize GCP token rate limiter (1 req/sec average, burst of 10) srv.gcpTokenRateLimiter = NewGCPTokenRateLimiter(1, 10) @@ -2454,6 +2478,11 @@ func (s *Server) CleanupResources(ctx context.Context) error { if s.logQueryService != nil { s.logQueryService.Close() } + if s.metricsDashboard != nil { + if err := s.metricsDashboard.Close(); err != nil { + slog.Warn("Failed to close metrics dashboard", "error", err) + } + } }) return nil } @@ -2566,6 +2595,8 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("/api/v1/admin/gcp-quota", s.handleAdminGCPQuota) s.mux.HandleFunc("/api/v1/admin/lifecycle-hooks", s.handleAdminLifecycleHooks) s.mux.HandleFunc("/api/v1/admin/lifecycle-hooks/", s.handleAdminLifecycleHookByID) + s.mux.HandleFunc("/api/v1/metrics/", s.handleMetricsDashboard) + s.mux.HandleFunc("/api/v1/admin/metrics-dashboard", s.handleAdminMetricsDashboard) // legacy backward-compat // Notification endpoints (user-facing) s.mux.HandleFunc("/api/v1/notifications", s.handleNotifications) diff --git a/pkg/hub/skill_federation.go b/pkg/hub/skill_federation.go index 54faecb80..bb35ee5d6 100644 --- a/pkg/hub/skill_federation.go +++ b/pkg/hub/skill_federation.go @@ -23,6 +23,8 @@ import ( "net/http" "strings" "time" + + "github.com/GoogleCloudPlatform/scion/pkg/store" ) const ( @@ -39,13 +41,13 @@ func (s *Server) federateResolve(ctx context.Context, registryName string, skill Message: fmt.Sprintf("registry %q is not configured", registryName), } } - if registry.Status != "active" { + if registry.Status != store.SkillRegistryStatusActive { return nil, &ResolveSkillError{ URI: skillRef.URI, Code: "registry_disabled", Message: fmt.Sprintf("registry %q is disabled", registryName), } } - if registry.Type != "hub" { + if registry.Type != store.SkillRegistryTypeHub { return nil, &ResolveSkillError{ URI: skillRef.URI, Code: "wrong_registry_type", Message: fmt.Sprintf("registry %q is type %q, not hub", registryName, registry.Type), @@ -124,7 +126,7 @@ func (s *Server) federateResolve(ctx context.Context, registryName string, skill resolved := &resolveResp.Resolved[0] // Trust enforcement - if registry.TrustLevel == "pinned" { + if registry.TrustLevel == store.SkillRegistryTrustPinned { pinnedHash, err := s.store.GetPinnedHash(ctx, registry.ID, skillRef.URI) if err != nil { return nil, &ResolveSkillError{ diff --git a/pkg/hub/skill_handlers.go b/pkg/hub/skill_handlers.go index 99287e7e0..6345aedba 100644 --- a/pkg/hub/skill_handlers.go +++ b/pkg/hub/skill_handlers.go @@ -15,17 +15,22 @@ package hub import ( + "bytes" "context" + "errors" "fmt" + "io" "net/http" "strconv" "strings" "github.com/Masterminds/semver/v3" + "golang.org/x/sync/errgroup" "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/storage" "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/GoogleCloudPlatform/scion/pkg/transfer" ) // CreateSkillRequest is the request body for creating a skill. @@ -680,6 +685,13 @@ func (s *Server) publishSkillVersion(w http.ResponseWriter, r *http.Request, ski return } + // Dispatch based on content type: multipart uploads are handled inline, + // while JSON requests go through the existing two-phase upload flow. + if ct := r.Header.Get("Content-Type"); strings.HasPrefix(ct, "multipart/form-data") { + s.publishSkillVersionMultipart(w, r, skill) + return + } + var req PublishVersionRequest if err := readJSON(r, &req); err != nil { BadRequest(w, "Invalid request body: "+err.Error()) @@ -697,34 +709,52 @@ func (s *Server) publishSkillVersion(w http.ResponseWriter, r *http.Request, ski return } - // Check for existing published version (immutability) + // Check for an existing version with this number. + // - Draft: reuse it (idempotent retry of an incomplete publish). + // - Published/deprecated/archived: reject as conflict. + var sv *store.SkillVersion existing, err := s.store.GetSkillVersionByNumber(ctx, skillID, req.Version) - if err == nil && existing.Status == store.SkillVersionStatusPublished { - writeError(w, http.StatusConflict, "conflict", - fmt.Sprintf("version %s is already published and immutable; publish a new version instead", req.Version), nil) + if err != nil && !errors.Is(err, store.ErrNotFound) { + writeErrorFromErr(w, err, "") return } - - // Create draft version - sv := &store.SkillVersion{ - ID: api.NewUUID(), - SkillID: skillID, - Version: req.Version, - Status: store.SkillVersionStatusDraft, + if err == nil { + switch existing.Status { + case store.SkillVersionStatusDraft: + sv = existing + case store.SkillVersionStatusPublished: + writeError(w, http.StatusConflict, "conflict", + fmt.Sprintf("version %s is already published and immutable; publish a new version instead", req.Version), nil) + return + default: + writeError(w, http.StatusConflict, "conflict", + fmt.Sprintf("version %s already exists with status %q", req.Version, existing.Status), nil) + return + } } - if identity := GetIdentityFromContext(ctx); identity != nil { - sv.PublisherID = identity.ID() - } + if sv == nil { + // Create new draft version + sv = &store.SkillVersion{ + ID: api.NewUUID(), + SkillID: skillID, + Version: req.Version, + Status: store.SkillVersionStatusDraft, + } - if err := s.store.CreateSkillVersion(ctx, sv); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { - writeError(w, http.StatusConflict, "conflict", - fmt.Sprintf("version %s already exists for this skill", req.Version), nil) + if identity := GetIdentityFromContext(ctx); identity != nil { + sv.PublisherID = identity.ID() + } + + if err := s.store.CreateSkillVersion(ctx, sv); err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + writeError(w, http.StatusConflict, "conflict", + fmt.Sprintf("version %s already exists for this skill", req.Version), nil) + return + } + writeErrorFromErr(w, err, "") return } - writeErrorFromErr(w, err, "") - return } response := PublishVersionResponse{ @@ -750,6 +780,202 @@ func (s *Server) publishSkillVersion(w http.ResponseWriter, r *http.Request, ski writeJSON(w, http.StatusCreated, response) } +// publishSkillVersionMultipart handles multipart/form-data skill version +// publishing. Files are uploaded inline in the request body rather than via +// the two-phase signed-URL flow. The caller has already authenticated and +// authorized the request. +func (s *Server) publishSkillVersionMultipart(w http.ResponseWriter, r *http.Request, skill *store.Skill) { + ctx := r.Context() + + // a) Size limit: 50 MB max request body. + r.Body = http.MaxBytesReader(w, r.Body, 50<<20) + + // b) Parse multipart form: 10 MB in memory, rest spills to disk. + if err := r.ParseMultipartForm(10 << 20); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "Request body exceeds 50MB limit", nil) + return + } + BadRequest(w, "Failed to parse multipart form: "+err.Error()) + return + } + defer r.MultipartForm.RemoveAll() + + // c) Extract and validate version. + version := r.FormValue("version") + if version == "" { + ValidationError(w, "version is required", nil) + return + } + if _, err := semver.NewVersion(version); err != nil { + ValidationError(w, fmt.Sprintf("invalid semver version %q: %s", version, err.Error()), nil) + return + } + + // d) Check for existing published version (immutability). + existing, err := s.store.GetSkillVersionByNumber(ctx, skill.ID, version) + if err == nil && existing.Status == store.SkillVersionStatusPublished { + writeError(w, http.StatusConflict, "conflict", + fmt.Sprintf("version %s is already published and immutable; publish a new version instead", version), nil) + return + } + + // e) Extract and validate files. + fileHeaders := r.MultipartForm.File["file"] + if len(fileHeaders) == 0 { + ValidationError(w, "at least one file is required", nil) + return + } + if len(fileHeaders) > 50 { + ValidationError(w, "too many files (max 50)", nil) + return + } + + hasSkillMD := false + seenFilenames := make(map[string]struct{}, len(fileHeaders)) + for _, fh := range fileHeaders { + name := fh.Filename + + // Security: reject dangerous filenames. + if name == "" { + ValidationError(w, "file has empty filename", nil) + return + } + if strings.Contains(name, "..") { + ValidationError(w, fmt.Sprintf("filename %q contains path traversal sequence", name), nil) + return + } + if strings.HasPrefix(name, "/") { + ValidationError(w, fmt.Sprintf("filename %q must not start with /", name), nil) + return + } + if strings.ContainsRune(name, 0) { + ValidationError(w, fmt.Sprintf("filename %q contains null byte", name), nil) + return + } + + // Duplicate filename check. + if _, dup := seenFilenames[name]; dup { + ValidationError(w, fmt.Sprintf("duplicate filename %q", name), nil) + return + } + seenFilenames[name] = struct{}{} + + // Per-file size limit: 10 MB. + if fh.Size > 10*1024*1024 { + ValidationError(w, fmt.Sprintf("file %q exceeds 10MB limit", name), nil) + return + } + + if name == "SKILL.md" { + hasSkillMD = true + } + } + if !hasSkillMD { + ValidationError(w, "SKILL.md is required", nil) + return + } + + // f) Create draft version. + sv := &store.SkillVersion{ + ID: api.NewUUID(), + SkillID: skill.ID, + Version: version, + Status: store.SkillVersionStatusDraft, + } + if identity := GetIdentityFromContext(ctx); identity != nil { + sv.PublisherID = identity.ID() + } + if err := s.store.CreateSkillVersion(ctx, sv); err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + writeError(w, http.StatusConflict, "conflict", + fmt.Sprintf("version %s already exists for this skill", version), nil) + return + } + writeErrorFromErr(w, err, "") + return + } + + // g) Upload files to storage and compute hashes. + stor := s.GetStorage() + if stor == nil { + _ = s.store.DeleteSkillVersion(ctx, sv.ID) + RuntimeError(w, "Storage not configured") + return + } + + type uploadResult struct { + file store.TemplateFile + } + results := make([]uploadResult, len(fileHeaders)) + + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(fileUploadConcurrency) + for i, fh := range fileHeaders { + i, fh := i, fh + g.Go(func() error { + if err := gctx.Err(); err != nil { + return err + } + + f, err := fh.Open() + if err != nil { + return fmt.Errorf("failed to open file %s: %w", fh.Filename, err) + } + defer f.Close() + + data, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", fh.Filename, err) + } + + hash := transfer.HashBytes(data) + objectPath := skill.StoragePath + "/" + version + "/" + fh.Filename + + if _, err := stor.Upload(gctx, objectPath, bytes.NewReader(data), storage.UploadOptions{}); err != nil { + return fmt.Errorf("failed to upload file %s: %w", fh.Filename, err) + } + + results[i] = uploadResult{ + file: store.TemplateFile{ + Path: fh.Filename, + Size: int64(len(data)), + Hash: hash, + }, + } + return nil + }) + } + if err := g.Wait(); err != nil { + _ = s.store.DeleteSkillVersion(ctx, sv.ID) + RuntimeError(w, "Failed to upload files: "+err.Error()) + return + } + + // Build manifest from results. + manifest := make([]store.TemplateFile, len(results)) + for i, r := range results { + manifest[i] = r.file + } + + // i) Compute content hash. + contentHash := computeContentHash(manifest) + + // j) Update version to published. + sv.Status = store.SkillVersionStatusPublished + sv.Files = manifest + sv.ContentHash = contentHash + if err := s.store.UpdateSkillVersion(ctx, sv); err != nil { + _ = s.store.DeleteSkillVersion(ctx, sv.ID) + writeErrorFromErr(w, err, "") + return + } + + // k) Return response. + writeJSON(w, http.StatusCreated, PublishVersionResponse{Version: sv}) +} + // handleSkillUpload handles requests for upload URLs for a skill. func (s *Server) handleSkillUpload(w http.ResponseWriter, r *http.Request, skillID string) { if r.Method != http.MethodPost { diff --git a/pkg/hub/skill_multipart_test.go b/pkg/hub/skill_multipart_test.go new file mode 100644 index 000000000..4c8571666 --- /dev/null +++ b/pkg/hub/skill_multipart_test.go @@ -0,0 +1,351 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !no_sqlite + +package hub + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "testing" + + "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// doMultipartRequestAsUser builds a multipart request, signs it as the given +// user, and executes it against the test server. +func doMultipartRequestAsUser( + t *testing.T, + srv *Server, + user *store.User, + method, path string, + fields map[string]string, + files map[string][]byte, +) *httptest.ResponseRecorder { + t.Helper() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + for k, v := range fields { + require.NoError(t, writer.WriteField(k, v)) + } + for name, content := range files { + part, err := writer.CreateFormFile("file", name) + require.NoError(t, err) + _, err = part.Write(content) + require.NoError(t, err) + } + require.NoError(t, writer.Close()) + + token, _, _, err := srv.userTokenService.GenerateTokenPair( + user.ID, user.Email, user.DisplayName, user.Role, ClientTypeWeb, + ) + require.NoError(t, err) + + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} + +// doMultipartRequestAsUserOrdered builds a multipart request with ordered file +// parts (needed when testing duplicate filenames or large file counts). +func doMultipartRequestAsUserOrdered( + t *testing.T, + srv *Server, + user *store.User, + method, path string, + version string, + fileParts []struct { + Name string + Content []byte + }, +) *httptest.ResponseRecorder { + t.Helper() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + require.NoError(t, writer.WriteField("version", version)) + for _, fp := range fileParts { + part, err := writer.CreateFormFile("file", fp.Name) + require.NoError(t, err) + _, err = part.Write(fp.Content) + require.NoError(t, err) + } + require.NoError(t, writer.Close()) + + token, _, _, err := srv.userTokenService.GenerateTokenPair( + user.ID, user.Email, user.DisplayName, user.Role, ClientTypeWeb, + ) + require.NoError(t, err) + + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec +} + +// setupMultipartTest creates a test server with storage configured, a user, a +// project, and a skill ready for multipart uploads. +func setupMultipartTest(t *testing.T) (srv *Server, s store.Store, alice *store.User, skill *store.Skill) { + t.Helper() + srv, s, alice, _, project := setupSkillAuthzTest(t) + stor := newMockStorage("test-bucket") + srv.SetStorage(stor) + skill = createTestSkill(t, s, "mp-skill-"+api.NewUUID()[:8], store.SkillScopeProject, project.ID, alice.ID) + return srv, s, alice, skill +} + +// TestMultipartPublish_HappyPath verifies that a well-formed multipart upload +// creates a published version with correct file manifest and content hash. +func TestMultipartPublish_HappyPath(t *testing.T) { + srv, s, alice, skill := setupMultipartTest(t) + + files := map[string][]byte{ + "SKILL.md": []byte("---\nname: test\n---\n# Test Skill"), + "helper.sh": []byte("#!/bin/bash\necho hello"), + } + rec := doMultipartRequestAsUser(t, srv, alice, http.MethodPost, + "/api/v1/skills/"+skill.ID+"/versions", + map[string]string{"version": "1.0.0"}, + files, + ) + + assert.Equal(t, http.StatusCreated, rec.Code, "expected 201; body: %s", rec.Body.String()) + + var resp PublishVersionResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + + assert.Equal(t, "1.0.0", resp.Version.Version) + assert.Equal(t, store.SkillVersionStatusPublished, resp.Version.Status) + assert.Len(t, resp.Version.Files, 2) + assert.Empty(t, resp.UploadURLs, "multipart path should not return upload URLs") + assert.NotEmpty(t, resp.Version.ContentHash, "content hash should be computed") + + // Verify each file has a valid hash and size. + for _, f := range resp.Version.Files { + assert.NotEmpty(t, f.Hash, "file %s should have a hash", f.Path) + assert.Greater(t, f.Size, int64(0), "file %s should have a positive size", f.Path) + } + + // Verify the version is persisted and published. + sv, err := s.GetSkillVersionByNumber(context.Background(), skill.ID, "1.0.0") + require.NoError(t, err) + assert.Equal(t, store.SkillVersionStatusPublished, sv.Status) + assert.Len(t, sv.Files, 2) +} + +// TestMultipartPublish_MissingSKILLMD verifies that omitting SKILL.md returns 400. +func TestMultipartPublish_MissingSKILLMD(t *testing.T) { + srv, _, alice, skill := setupMultipartTest(t) + + files := map[string][]byte{ + "helper.sh": []byte("#!/bin/bash\necho hello"), + } + rec := doMultipartRequestAsUser(t, srv, alice, http.MethodPost, + "/api/v1/skills/"+skill.ID+"/versions", + map[string]string{"version": "1.0.0"}, + files, + ) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "expected 400; body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "SKILL.md") +} + +// TestMultipartPublish_InvalidSemver verifies that a bad version string returns 400. +func TestMultipartPublish_InvalidSemver(t *testing.T) { + srv, _, alice, skill := setupMultipartTest(t) + + files := map[string][]byte{ + "SKILL.md": []byte("---\nname: test\n---\n# Test"), + } + rec := doMultipartRequestAsUser(t, srv, alice, http.MethodPost, + "/api/v1/skills/"+skill.ID+"/versions", + map[string]string{"version": "bad"}, + files, + ) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "expected 400; body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "invalid semver") +} + +// TestMultipartPublish_DuplicateVersion verifies that re-publishing an already +// published version returns 409 Conflict. +func TestMultipartPublish_DuplicateVersion(t *testing.T) { + srv, s, alice, skill := setupMultipartTest(t) + + // Pre-create a published version. + sv := &store.SkillVersion{ + ID: api.NewUUID(), + SkillID: skill.ID, + Version: "1.0.0", + Status: store.SkillVersionStatusPublished, + } + require.NoError(t, s.CreateSkillVersion(context.Background(), sv)) + + files := map[string][]byte{ + "SKILL.md": []byte("---\nname: test\n---\n# Test"), + } + rec := doMultipartRequestAsUser(t, srv, alice, http.MethodPost, + "/api/v1/skills/"+skill.ID+"/versions", + map[string]string{"version": "1.0.0"}, + files, + ) + + assert.Equal(t, http.StatusConflict, rec.Code, "expected 409; body: %s", rec.Body.String()) +} + +// TestMultipartPublish_PathTraversal verifies that filenames containing ".." +// are rejected. Go's multipart parser (since Go 1.22) sanitizes most path +// traversal patterns (e.g. "../evil.txt" -> "evil.txt") via filepath.Base, but +// the bare filename ".." passes through. The server-side check provides +// defense-in-depth against future parser changes or non-Go clients. +func TestMultipartPublish_PathTraversal(t *testing.T) { + srv, _, alice, skill := setupMultipartTest(t) + + // Go's multipart parser keeps ".." as a bare filename, so we use a raw + // Content-Disposition header to submit it. + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("version", "1.0.0")) + + // Write SKILL.md normally. + part, err := writer.CreateFormFile("file", "SKILL.md") + require.NoError(t, err) + _, err = part.Write([]byte("---\nname: test\n---\n# Test")) + require.NoError(t, err) + + // Write a file whose name is literally ".." — Go's parser does not + // sanitize this. + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="file"; filename=".."`) + h.Set("Content-Type", "application/octet-stream") + evilPart, err := writer.CreatePart(h) + require.NoError(t, err) + _, err = evilPart.Write([]byte("pwned")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + token, _, _, err := srv.userTokenService.GenerateTokenPair( + alice.ID, alice.Email, alice.DisplayName, alice.Role, ClientTypeWeb, + ) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/skills/"+skill.ID+"/versions", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "expected 400; body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "path traversal") +} + +// TestMultipartPublish_DuplicateFilenames verifies that two files with the same +// name are rejected. +func TestMultipartPublish_DuplicateFilenames(t *testing.T) { + srv, _, alice, skill := setupMultipartTest(t) + + parts := []struct { + Name string + Content []byte + }{ + {Name: "SKILL.md", Content: []byte("---\nname: test\n---\n# First")}, + {Name: "SKILL.md", Content: []byte("---\nname: test\n---\n# Second")}, + } + + rec := doMultipartRequestAsUserOrdered(t, srv, alice, http.MethodPost, + "/api/v1/skills/"+skill.ID+"/versions", + "1.0.0", + parts, + ) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "expected 400; body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "duplicate filename") +} + +// TestMultipartPublish_TooManyFiles verifies that uploading more than 50 files +// returns 400. +func TestMultipartPublish_TooManyFiles(t *testing.T) { + srv, _, alice, skill := setupMultipartTest(t) + + parts := make([]struct { + Name string + Content []byte + }, 51) + parts[0] = struct { + Name string + Content []byte + }{Name: "SKILL.md", Content: []byte("---\nname: test\n---\n# Test")} + for i := 1; i < 51; i++ { + parts[i] = struct { + Name string + Content []byte + }{Name: fmt.Sprintf("file-%d.txt", i), Content: []byte("content")} + } + + rec := doMultipartRequestAsUserOrdered(t, srv, alice, http.MethodPost, + "/api/v1/skills/"+skill.ID+"/versions", + "1.0.0", + parts, + ) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "expected 400; body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "too many files") +} + +// TestMultipartPublish_JSONPathStillWorks verifies that a JSON Content-Type +// request continues to use the existing two-phase flow. +func TestMultipartPublish_JSONPathStillWorks(t *testing.T) { + srv, _, alice, skill := setupMultipartTest(t) + + rec := doRequestAsUser(t, srv, alice, http.MethodPost, + "/api/v1/skills/"+skill.ID+"/versions", + PublishVersionRequest{ + Version: "1.0.0", + Files: []FileUploadRequest{ + {Path: "SKILL.md", Size: 100}, + }, + }, + ) + + assert.Equal(t, http.StatusCreated, rec.Code, "expected 201; body: %s", rec.Body.String()) + + var resp PublishVersionResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + + assert.Equal(t, "1.0.0", resp.Version.Version) + assert.Equal(t, store.SkillVersionStatusDraft, resp.Version.Status, + "JSON path should create a draft, not immediately publish") +} diff --git a/pkg/hub/skill_registry_handlers.go b/pkg/hub/skill_registry_handlers.go index 7c4bf798c..7984fc37b 100644 --- a/pkg/hub/skill_registry_handlers.go +++ b/pkg/hub/skill_registry_handlers.go @@ -36,12 +36,12 @@ type CreateSkillRegistryRequest struct { // UpdateSkillRegistryRequest is the request body for updating a skill registry. type UpdateSkillRegistryRequest struct { - Endpoint string `json:"endpoint,omitempty"` - Description string `json:"description,omitempty"` - TrustLevel string `json:"trustLevel,omitempty"` - AuthToken string `json:"authToken,omitempty"` - ResolvePath string `json:"resolvePath,omitempty"` - Status string `json:"status,omitempty"` + Endpoint *string `json:"endpoint,omitempty"` + Description *string `json:"description,omitempty"` + TrustLevel *string `json:"trustLevel,omitempty"` + AuthToken *string `json:"authToken,omitempty"` + ResolvePath *string `json:"resolvePath,omitempty"` + Status *string `json:"status,omitempty"` } // PinSkillHashRequest is the request body for pinning a skill hash. @@ -238,36 +238,36 @@ func (s *Server) updateSkillRegistry(w http.ResponseWriter, r *http.Request, id return } - if req.Endpoint != "" { - u, err := url.Parse(req.Endpoint) + if req.Endpoint != nil { + u, err := url.Parse(*req.Endpoint) if err != nil || u.Scheme != "https" || u.Host == "" { ValidationError(w, "endpoint must be a valid HTTPS URL", nil) return } - registry.Endpoint = req.Endpoint + registry.Endpoint = *req.Endpoint } - if req.Description != "" { - registry.Description = req.Description + if req.Description != nil { + registry.Description = *req.Description } - if req.TrustLevel != "" { - if req.TrustLevel != store.SkillRegistryTrustTrusted && req.TrustLevel != store.SkillRegistryTrustPinned { + if req.TrustLevel != nil { + if *req.TrustLevel != store.SkillRegistryTrustTrusted && *req.TrustLevel != store.SkillRegistryTrustPinned { ValidationError(w, "trustLevel must be 'trusted' or 'pinned'", nil) return } - registry.TrustLevel = req.TrustLevel + registry.TrustLevel = *req.TrustLevel } - if req.AuthToken != "" { - registry.AuthToken = req.AuthToken + if req.AuthToken != nil { + registry.AuthToken = *req.AuthToken } - if req.ResolvePath != "" { - registry.ResolvePath = req.ResolvePath + if req.ResolvePath != nil { + registry.ResolvePath = *req.ResolvePath } - if req.Status != "" { - if req.Status != store.SkillRegistryStatusActive && req.Status != store.SkillRegistryStatusDisabled { + if req.Status != nil { + if *req.Status != store.SkillRegistryStatusActive && *req.Status != store.SkillRegistryStatusDisabled { ValidationError(w, "status must be 'active' or 'disabled'", nil) return } - registry.Status = req.Status + registry.Status = *req.Status } if err := s.store.UpdateSkillRegistry(ctx, registry); err != nil { diff --git a/pkg/hub/skill_registry_handlers_test.go b/pkg/hub/skill_registry_handlers_test.go index 7fa18bedc..940993208 100644 --- a/pkg/hub/skill_registry_handlers_test.go +++ b/pkg/hub/skill_registry_handlers_test.go @@ -261,6 +261,73 @@ func TestSkillRegistryCRUD_AuthTokenNotInResponse(t *testing.T) { } } +func TestSkillRegistryCRUD_ClearAuthTokenAndResolvePath(t *testing.T) { + srv, st := newRegistryTestServer(t) + admin := NewAuthenticatedUser("admin-1", "admin@test.com", "Admin", "admin", "cli") + + // Create registry with non-empty AuthToken and ResolvePath + body := `{"name":"clear-reg","endpoint":"https://registry.example.com","authToken":"my-token","resolvePath":"/custom/resolve"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/skill-registries", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(contextWithIdentity(req.Context(), admin)) + rr := httptest.NewRecorder() + srv.handleSkillRegistries(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("create: expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + + var created store.SkillRegistry + if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil { + t.Fatalf("create: invalid JSON: %v", err) + } + + // Verify initial values via store (AuthToken is json:"-") + got, err := st.GetSkillRegistry(req.Context(), created.ID) + if err != nil { + t.Fatalf("get after create: %v", err) + } + if got.AuthToken != "my-token" { + t.Fatalf("expected authToken 'my-token', got %q", got.AuthToken) + } + if got.ResolvePath != "/custom/resolve" { + t.Fatalf("expected resolvePath '/custom/resolve', got %q", got.ResolvePath) + } + + // Update with explicit empty strings to clear both fields + updateBody := `{"authToken":"","resolvePath":""}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/skill-registries/"+created.ID, strings.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(contextWithIdentity(req.Context(), admin)) + rr = httptest.NewRecorder() + srv.handleSkillRegistryByID(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("update: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + // Verify resolvePath is cleared in response (omitempty means absent when empty) + var updatedResp map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &updatedResp); err != nil { + t.Fatalf("update response: invalid JSON: %v", err) + } + if _, ok := updatedResp["resolvePath"]; ok { + t.Errorf("expected resolvePath absent from response (omitempty), got %v", updatedResp["resolvePath"]) + } + + // Verify fields are cleared via store (authoritative check) + got, err = st.GetSkillRegistry(req.Context(), created.ID) + if err != nil { + t.Fatalf("get after update: %v", err) + } + if got.AuthToken != "" { + t.Errorf("expected authToken cleared, got %q", got.AuthToken) + } + if got.ResolvePath != "" { + t.Errorf("expected resolvePath cleared, got %q", got.ResolvePath) + } +} + func TestSkillRegistryPin(t *testing.T) { srv, _ := newRegistryTestServer(t) admin := NewAuthenticatedUser("admin-1", "admin@test.com", "Admin", "admin", "cli") diff --git a/pkg/hub/template_bootstrap.go b/pkg/hub/template_bootstrap.go index 95f80fd22..8aaa197a3 100644 --- a/pkg/hub/template_bootstrap.go +++ b/pkg/hub/template_bootstrap.go @@ -112,14 +112,14 @@ func (s *Server) BootstrapTemplatesFromDir(ctx context.Context, templatesDir str // behavior (harness detection, DefaultHarnessConfig backfill, bundled // harness-config import) lives in templatePersistence. func (s *Server) syncExistingTemplate(ctx context.Context, existing *store.Template, templatePath string, force bool) (bool, error) { - return s.templateStore().Bootstrap(ctx, existing.Name, templatePath, existing.Scope, existing.ScopeID, force) + return s.templateStore().Bootstrap(ctx, existing.Name, templatePath, existing.Scope, existing.ScopeID, "", force) } // bootstrapSingleTemplate imports one local template directory into the // Hub's database and storage backend under the given scope and projectID. // For global templates pass store.TemplateScopeGlobal and "". func (s *Server) bootstrapSingleTemplate(ctx context.Context, name, templatePath, scope, projectID string) error { - _, err := s.templateStore().Bootstrap(ctx, name, templatePath, scope, projectID, false) + _, err := s.templateStore().Bootstrap(ctx, name, templatePath, scope, projectID, "", false) return err } diff --git a/pkg/hub/web.go b/pkg/hub/web.go index 8e8806e78..033e7d17d 100644 --- a/pkg/hub/web.go +++ b/pkg/hub/web.go @@ -765,7 +765,10 @@ func (ws *WebServer) staticHandler() http.Handler { func (ws *WebServer) serveStaticAsset(w http.ResponseWriter, r *http.Request) { if ws.assetsDisk == "" && ws.assets == nil { - http.Error(w, "no assets available", http.StatusNotFound) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, noAssetsPage) return } diff --git a/pkg/hubclient/harness_configs.go b/pkg/hubclient/harness_configs.go index 200d87aad..bcba982f0 100644 --- a/pkg/hubclient/harness_configs.go +++ b/pkg/hubclient/harness_configs.go @@ -67,6 +67,9 @@ type HarnessConfigService interface { // ReadFile reads a harness config file through the Hub API. ReadFile(ctx context.Context, id, filePath string) ([]byte, error) + + // Reimport re-imports a harness config from its stored source URL or an override. + Reimport(ctx context.Context, id string, sourceURL string) (*ReimportHarnessConfigResponse, error) } // harnessConfigService is the implementation of HarnessConfigService. @@ -133,6 +136,17 @@ type HarnessConfigFinalizeRequest struct { Manifest *HarnessConfigManifest `json:"manifest"` } +// ReimportHarnessConfigRequest is the request body for reimporting a harness config. +type ReimportHarnessConfigRequest struct { + SourceURL string `json:"sourceUrl,omitempty"` +} + +// ReimportHarnessConfigResponse is the response from reimporting a harness config. +type ReimportHarnessConfigResponse struct { + HarnessConfigs []string `json:"harnessConfigs"` + Count int `json:"count"` +} + type harnessConfigFileContentResponse struct { Path string `json:"path"` Content string `json:"content"` @@ -315,6 +329,16 @@ func (s *harnessConfigService) ReadFile(ctx context.Context, id, filePath string return []byte(result.Content), nil } +// Reimport re-imports a harness config from its stored source URL or an override. +func (s *harnessConfigService) Reimport(ctx context.Context, id string, sourceURL string) (*ReimportHarnessConfigResponse, error) { + req := ReimportHarnessConfigRequest{SourceURL: sourceURL} + resp, err := s.c.post(ctx, "/api/v1/harness-configs/"+id+"/reimport", req, nil) + if err != nil { + return nil, err + } + return apiclient.DecodeResponse[ReimportHarnessConfigResponse](resp) +} + func (s *harnessConfigService) getTransferClient() *transfer.Client { if s.transferClient == nil { s.transferClient = transfer.NewClient(s.c.transport.AuthenticatedHTTPClient()) diff --git a/pkg/hubclient/skill_registries.go b/pkg/hubclient/skill_registries.go index 848cbb1b6..e2f1239b4 100644 --- a/pkg/hubclient/skill_registries.go +++ b/pkg/hubclient/skill_registries.go @@ -69,12 +69,12 @@ type CreateSkillRegistryRequest struct { // UpdateSkillRegistryRequest is the request body for updating a skill registry. type UpdateSkillRegistryRequest struct { - Endpoint string `json:"endpoint,omitempty"` - Description string `json:"description,omitempty"` - TrustLevel string `json:"trustLevel,omitempty"` - AuthToken string `json:"authToken,omitempty"` - ResolvePath string `json:"resolvePath,omitempty"` - Status string `json:"status,omitempty"` + Endpoint *string `json:"endpoint,omitempty"` + Description *string `json:"description,omitempty"` + TrustLevel *string `json:"trustLevel,omitempty"` + AuthToken *string `json:"authToken,omitempty"` + ResolvePath *string `json:"resolvePath,omitempty"` + Status *string `json:"status,omitempty"` } // PinSkillHashRequest is the request body for pinning a skill hash. diff --git a/pkg/hubclient/types.go b/pkg/hubclient/types.go index 45fe4f6f6..704d3b99e 100644 --- a/pkg/hubclient/types.go +++ b/pkg/hubclient/types.go @@ -538,6 +538,7 @@ type HarnessConfig struct { StorageBucket string `json:"storageBucket,omitempty"` StoragePath string `json:"storagePath,omitempty"` Files []TemplateFile `json:"files,omitempty"` + SourceURL string `json:"sourceUrl,omitempty"` Locked bool `json:"locked,omitempty"` Status string `json:"status"` OwnerID string `json:"ownerId,omitempty"` diff --git a/pkg/lifecyclehooks/varguard.go b/pkg/lifecyclehooks/varguard.go index 1b1ae43c4..389fa309c 100644 --- a/pkg/lifecyclehooks/varguard.go +++ b/pkg/lifecyclehooks/varguard.go @@ -52,6 +52,7 @@ var TrustedVars = map[string]VarTrust{ // Project metadata (Hub-controlled) "PROJECT_ID": Trusted, "PROJECT_NAME": Trusted, + "PROJECT_SLUG": Trusted, // Hub-controlled agent identity (set by Hub, not agent) "AGENT_ID": Trusted, diff --git a/pkg/lifecyclehooks/varguard_test.go b/pkg/lifecyclehooks/varguard_test.go index 8c0c4e2d3..c3f466370 100644 --- a/pkg/lifecyclehooks/varguard_test.go +++ b/pkg/lifecyclehooks/varguard_test.go @@ -35,6 +35,7 @@ func TestClassifyVar(t *testing.T) { // Trusted {"trusted: HOOK_ID", "HOOK_ID", Trusted}, {"trusted: PROJECT_ID", "PROJECT_ID", Trusted}, + {"trusted: PROJECT_SLUG", "PROJECT_SLUG", Trusted}, {"trusted: AGENT_ID", "AGENT_ID", Trusted}, {"trusted: SA_EMAIL", "SA_EMAIL", Trusted}, diff --git a/pkg/sciontool/telemetry/config_test.go b/pkg/sciontool/telemetry/config_test.go index 320cf7acd..1ff5ee5d0 100644 --- a/pkg/sciontool/telemetry/config_test.go +++ b/pkg/sciontool/telemetry/config_test.go @@ -448,11 +448,10 @@ func TestLoadConfig_GCPDefaults(t *testing.T) { cfg := LoadConfig() - // Note: GCPCredentialsFile may be non-empty if the well-known path exists - // in the test environment's home directory. Only assert CloudProvider is - // empty when no credentials are present. + // CloudProvider is auto-detected when a credentials file exists at the + // well-known path. Only assert it is empty when no credentials are found. if cfg.GCPCredentialsFile == "" && cfg.CloudProvider != "" { - t.Errorf("Expected CloudProvider to be empty when no credentials, got %q", cfg.CloudProvider) + t.Errorf("Expected CloudProvider to be empty when no GCP credentials, got %q", cfg.CloudProvider) } } diff --git a/pkg/sciontool/telemetry/pipeline.go b/pkg/sciontool/telemetry/pipeline.go index bce065d5b..17cfbfa04 100644 --- a/pkg/sciontool/telemetry/pipeline.go +++ b/pkg/sciontool/telemetry/pipeline.go @@ -10,6 +10,8 @@ import ( "fmt" "log/slog" "os" + "sort" + "strconv" "strings" "sync" "time" @@ -18,12 +20,18 @@ import ( "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric/noop" + commonpb "go.opentelemetry.io/proto/otlp/common/v1" logspb "go.opentelemetry.io/proto/otlp/logs/v1" metricpb "go.opentelemetry.io/proto/otlp/metrics/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "google.golang.org/api/googleapi" ) +// metricFlushInterval is the minimum interval between metric exports to Cloud +// Monitoring. This prevents sampling-rate violations when multiple short-lived +// processes (hooks) send metrics in rapid succession. +const metricFlushInterval = 15 * time.Second + // Pipeline orchestrates the telemetry collection and forwarding. type Pipeline struct { config *Config @@ -35,6 +43,17 @@ type Pipeline struct { healthCancel context.CancelFunc exportErrors otelmetric.Int64Counter meter otelmetric.Meter + + metricsDropWarned sync.Once + logsDropWarned sync.Once + spansDropWarned sync.Once + + metricBuf []*metricpb.ResourceMetrics + metricBufMu sync.Mutex + metricFlushCtx context.Context + metricFlushCnl context.CancelFunc + metricLastFlush time.Time + metricFlushWg sync.WaitGroup } // New creates a new telemetry pipeline. @@ -135,6 +154,16 @@ func (p *Pipeline) Start(ctx context.Context) error { p.running = true + // Start metric flush goroutine for batching exports to Cloud Monitoring. + if p.exporter != nil { + p.metricFlushCtx, p.metricFlushCnl = context.WithCancel(ctx) + p.metricFlushWg.Add(1) + go func() { + defer p.metricFlushWg.Done() + p.metricFlushLoop() + }() + } + // Register pipeline health gauge and export error counter. if p.config.IsCloudConfigured() && p.exporter != nil { p.initSelfMetrics(ctx) @@ -166,6 +195,14 @@ func (p *Pipeline) Stop(ctx context.Context) error { p.healthCancel = nil } + // Stop metric flush goroutine and drain remaining buffered metrics. + if p.metricFlushCnl != nil { + p.metricFlushCnl() + p.metricFlushCnl = nil + } + p.metricFlushWg.Wait() + p.flushMetricBuffer(ctx, true) + // Stop receiver first if p.receiver != nil { if err := p.receiver.Stop(ctx); err != nil { @@ -231,6 +268,10 @@ func (p *Pipeline) handleSpans(ctx context.Context, resourceSpans []*tracepb.Res return err } log.Debug("Exported %d spans to cloud", spanCount) + } else { + p.spansDropWarned.Do(func() { + log.Error("Received %d spans but cloud exporter is not configured — spans will be dropped. Set SCION_GCP_PROJECT_ID or configure telemetry.cloud", spanCount) + }) } return nil @@ -276,27 +317,236 @@ func (p *Pipeline) filterSpans(resourceSpans []*tracepb.ResourceSpans) []*tracep return result } -// handleMetrics processes incoming metrics from the receiver. +// handleMetrics buffers incoming metrics for periodic export to Cloud Monitoring. +// Metrics are accumulated and flushed at metricFlushInterval to avoid +// sampling-rate violations from rapid writes (e.g. multiple hook processes). func (p *Pipeline) handleMetrics(ctx context.Context, resourceMetrics []*metricpb.ResourceMetrics) error { if len(resourceMetrics) == 0 { return nil } - // Forward to cloud exporter if available. In GCP-native mode, OTLP metrics - // received by this pipeline are converted and forwarded to Cloud Monitoring. - // Sciontool's own SDK metrics may still bypass the pipeline and export - // directly via a MeterProvider. if p.exporter != nil { - if err := p.exporter.ExportProtoMetrics(ctx, resourceMetrics); err != nil { - p.recordExportError(ctx, "metrics", err) - log.Error("Failed to export metrics to cloud: %v", err) - return err + p.metricBufMu.Lock() + p.metricBuf = append(p.metricBuf, resourceMetrics...) + p.metricBufMu.Unlock() + log.Debug("Buffered %d resource metric batches for export", len(resourceMetrics)) + } else { + metricCount := 0 + for _, rm := range resourceMetrics { + for _, sm := range rm.ScopeMetrics { + metricCount += len(sm.Metrics) + } } + p.metricsDropWarned.Do(func() { + log.Error("Received %d metrics but cloud exporter is not configured — metrics will be dropped. Set SCION_GCP_PROJECT_ID or configure telemetry.cloud", metricCount) + }) } return nil } +// metricFlushLoop periodically flushes buffered metrics to Cloud Monitoring. +func (p *Pipeline) metricFlushLoop() { + ticker := time.NewTicker(metricFlushInterval) + defer ticker.Stop() + for { + select { + case <-p.metricFlushCtx.Done(): + return + case <-ticker.C: + p.flushMetricBuffer(p.metricFlushCtx, false) + } + } +} + +// flushMetricBuffer deduplicates and exports buffered metrics to Cloud Monitoring. +// For cumulative metrics from short-lived hook processes, multiple data points may +// exist for the same metric+attributes combination. Only the latest data point is +// kept to avoid Cloud Monitoring sampling-rate violations. +// +// Cloud Monitoring requires a minimum 10-second interval between writes for the +// same time series. This method enforces metricFlushInterval between exports to +// prevent rapid consecutive flushes (e.g. periodic tick followed by shutdown drain) +// from triggering sampling-rate rejections. Pass force=true during shutdown to +// bypass the interval check and drain all remaining buffered metrics. +func (p *Pipeline) flushMetricBuffer(ctx context.Context, force bool) { + p.metricBufMu.Lock() + buf := p.metricBuf + sinceLastFlush := time.Since(p.metricLastFlush) + if len(buf) > 0 && !force && sinceLastFlush < metricFlushInterval { + p.metricBufMu.Unlock() + log.Debug("Skipping metric flush — last export was %v ago (minimum %v)", sinceLastFlush.Round(time.Millisecond), metricFlushInterval) + return + } + p.metricBuf = nil + p.metricBufMu.Unlock() + + if len(buf) == 0 || p.exporter == nil { + return + } + + deduped := deduplicateMetrics(buf) + + metricCount := 0 + for _, rm := range deduped { + for _, sm := range rm.ScopeMetrics { + metricCount += len(sm.Metrics) + } + } + + if err := p.exporter.ExportProtoMetrics(ctx, deduped); err != nil { + p.recordExportError(ctx, "metrics", err) + log.Error("Failed to export %d buffered metrics to cloud: %v", metricCount, err) + return + } + p.metricBufMu.Lock() + p.metricLastFlush = time.Now() + p.metricBufMu.Unlock() + log.Debug("Exported %d buffered metrics to cloud", metricCount) +} + +// deduplicateMetrics merges multiple ResourceMetrics into one, keeping only the +// latest data point per (metric name, attribute set) for Sum metrics. This +// prevents Cloud Monitoring sampling-rate violations when multiple hook processes +// report the same cumulative counter within a short window. +func deduplicateMetrics(rms []*metricpb.ResourceMetrics) []*metricpb.ResourceMetrics { + if len(rms) <= 1 { + return rms + } + + // Flatten all metrics into a single ResourceMetrics, deduplicating data points. + // Key: "scope/metricname" → Metric with deduplicated data points. + type metricKey struct { + scope string + metric string + } + latest := make(map[metricKey]*metricpb.Metric) + + var resource *metricpb.ResourceMetrics + for _, rm := range rms { + if rm == nil { + continue + } + if resource == nil { + resource = rm + } + for _, sm := range rm.ScopeMetrics { + scopeName := "" + if sm.Scope != nil { + scopeName = sm.Scope.Name + } + for _, m := range sm.Metrics { + key := metricKey{scope: scopeName, metric: m.Name} + existing, ok := latest[key] + if !ok { + latest[key] = m + continue + } + // For Sum metrics, keep only the data point with the latest timestamp + // per attribute set. For other types, keep the latest metric entirely. + merged := mergeMetricDataPoints(existing, m) + latest[key] = merged + } + } + } + + if resource == nil || len(latest) == 0 { + return nil + } + + // Rebuild scope→metrics structure. + scopeMetrics := make(map[string][]*metricpb.Metric) + for key, m := range latest { + scopeMetrics[key.scope] = append(scopeMetrics[key.scope], m) + } + sms := make([]*metricpb.ScopeMetrics, 0, len(scopeMetrics)) + for _, metrics := range scopeMetrics { + sms = append(sms, &metricpb.ScopeMetrics{Metrics: metrics}) + } + return []*metricpb.ResourceMetrics{{ + Resource: resource.Resource, + ScopeMetrics: sms, + }} +} + +// mergeMetricDataPoints merges two proto metrics with the same name, keeping +// the latest data point per attribute set for Sum types. +func mergeMetricDataPoints(a, b *metricpb.Metric) *metricpb.Metric { + aSum, aOK := a.Data.(*metricpb.Metric_Sum) + bSum, bOK := b.Data.(*metricpb.Metric_Sum) + if !aOK || !bOK { + // For non-Sum metrics, keep the one with the latest timestamp + return b + } + + // Dedup by attribute set: keep the data point with the latest TimeUnixNano. + type attrKey string + pointMap := make(map[attrKey]*metricpb.NumberDataPoint) + for _, dp := range aSum.Sum.DataPoints { + key := attrKey(attrSetKey(dp.Attributes)) + pointMap[key] = dp + } + for _, dp := range bSum.Sum.DataPoints { + key := attrKey(attrSetKey(dp.Attributes)) + existing, ok := pointMap[key] + if !ok || dp.TimeUnixNano > existing.TimeUnixNano { + pointMap[key] = dp + } + } + + merged := make([]*metricpb.NumberDataPoint, 0, len(pointMap)) + for _, dp := range pointMap { + merged = append(merged, dp) + } + return &metricpb.Metric{ + Name: a.Name, + Description: a.Description, + Unit: a.Unit, + Data: &metricpb.Metric_Sum{ + Sum: &metricpb.Sum{ + DataPoints: merged, + AggregationTemporality: aSum.Sum.AggregationTemporality, + IsMonotonic: aSum.Sum.IsMonotonic, + }, + }, + } +} + +// attrSetKey creates a stable string key from a list of proto attributes. +// Attributes are copied and sorted by key to ensure a stable, order-independent key. +func attrSetKey(attrs []*commonpb.KeyValue) string { + if len(attrs) == 0 { + return "" + } + sorted := make([]*commonpb.KeyValue, len(attrs)) + copy(sorted, attrs) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Key < sorted[j].Key + }) + + var b strings.Builder + for i, kv := range sorted { + if i > 0 { + b.WriteByte(';') + } + b.WriteString(kv.Key) + b.WriteByte('=') + if kv.Value != nil { + switch v := kv.Value.GetValue().(type) { + case *commonpb.AnyValue_StringValue: + b.WriteString(v.StringValue) + case *commonpb.AnyValue_BoolValue: + b.WriteString(strconv.FormatBool(v.BoolValue)) + case *commonpb.AnyValue_IntValue: + b.WriteString(strconv.FormatInt(v.IntValue, 10)) + case *commonpb.AnyValue_DoubleValue: + b.WriteString(strconv.FormatFloat(v.DoubleValue, 'f', -1, 64)) + } + } + } + return b.String() +} + // handleLogs processes incoming logs from the receiver. func (p *Pipeline) handleLogs(ctx context.Context, resourceLogs []*logspb.ResourceLogs) error { if len(resourceLogs) == 0 { @@ -319,6 +569,10 @@ func (p *Pipeline) handleLogs(ctx context.Context, resourceLogs []*logspb.Resour return err } log.Debug("Exported %d log records to cloud", logCount) + } else { + p.logsDropWarned.Do(func() { + log.Error("Received %d log records but cloud exporter is not configured — logs will be dropped. Set SCION_GCP_PROJECT_ID or configure telemetry.cloud", logCount) + }) } return nil diff --git a/pkg/sciontool/telemetry/providers.go b/pkg/sciontool/telemetry/providers.go index c951f308a..754cc62f9 100644 --- a/pkg/sciontool/telemetry/providers.go +++ b/pkg/sciontool/telemetry/providers.go @@ -139,20 +139,40 @@ func newGCPProviders(ctx context.Context, config *Config, res *resource.Resource return nil, fmt.Errorf("creating log exporter: %w", err) } - // Metrics use the GCP Cloud Monitoring exporter (direct to Cloud Monitoring API) - metricOpts := []mexporter.Option{ - mexporter.WithProjectID(config.ProjectID), - } - if len(clientOpts) > 0 { - metricOpts = append(metricOpts, mexporter.WithMonitoringClientOptions(clientOpts...)) - } - rawMetricExporter, err := mexporter.New(metricOpts...) - if err != nil { - _ = traceExporter.Shutdown(ctx) - _ = logExporter.Shutdown(ctx) - return nil, fmt.Errorf("creating GCP metric exporter: %w", err) + // Metrics: short-lived processes (hooks, batch=false) route through the + // local pipeline receiver via OTLP, just like logs. The long-running + // pipeline aggregates and exports to Cloud Monitoring at safe intervals, + // avoiding sampling-rate violations that occur when each hook process + // creates an independent Cloud Monitoring exporter. + // Long-lived processes (init, batch=true) export directly to Cloud + // Monitoring since they maintain stable cumulative counters. + var metricExporter metric.Exporter + if batch { + metricOpts := []mexporter.Option{ + mexporter.WithProjectID(config.ProjectID), + } + if len(clientOpts) > 0 { + metricOpts = append(metricOpts, mexporter.WithMonitoringClientOptions(clientOpts...)) + } + rawMetricExporter, err := mexporter.New(metricOpts...) + if err != nil { + _ = traceExporter.Shutdown(ctx) + _ = logExporter.Shutdown(ctx) + return nil, fmt.Errorf("creating GCP metric exporter: %w", err) + } + metricExporter = rawMetricExporter + } else { + otlpMetricExp, err := otlpmetricgrpc.New(ctx, + otlpmetricgrpc.WithEndpoint(fmt.Sprintf("localhost:%d", config.GRPCPort)), + otlpmetricgrpc.WithInsecure(), + ) + if err != nil { + _ = traceExporter.Shutdown(ctx) + _ = logExporter.Shutdown(ctx) + return nil, fmt.Errorf("creating OTLP metric exporter for pipeline: %w", err) + } + metricExporter = otlpMetricExp } - var metricExporter metric.Exporter = rawMetricExporter if config.MetricsDebug { metricExporter = newDebugMetricExporter(metricExporter) } diff --git a/pkg/store/entadapter/external_store.go b/pkg/store/entadapter/external_store.go index d85846876..aa0f9c659 100644 --- a/pkg/store/entadapter/external_store.go +++ b/pkg/store/entadapter/external_store.go @@ -62,6 +62,9 @@ func entGCPToStore(e *ent.GCPServiceAccount) *store.GCPServiceAccount { Managed: e.Managed, ManagedBy: e.ManagedBy, } + if e.Verified { + sa.VerificationStatus = "verified" + } // default_scopes is stored as a CSV string for parity with the SQLite store. if e.DefaultScopes != "" { sa.DefaultScopes = strings.Split(e.DefaultScopes, ",") diff --git a/pkg/store/entadapter/skill_registry_store.go b/pkg/store/entadapter/skill_registry_store.go index a57233139..342a1d3e8 100644 --- a/pkg/store/entadapter/skill_registry_store.go +++ b/pkg/store/entadapter/skill_registry_store.go @@ -58,7 +58,7 @@ func entSkillRegistryToStore(e *ent.SkillRegistry) *store.SkillRegistry { func (s *SkillRegistryStore) CreateSkillRegistry(ctx context.Context, registry *store.SkillRegistry) error { pinnedHashesJSON := "" - if len(registry.PinnedHashes) > 0 { + if registry.PinnedHashes != nil { b, _ := json.Marshal(registry.PinnedHashes) pinnedHashesJSON = string(b) } @@ -135,16 +135,12 @@ func (s *SkillRegistryStore) UpdateSkillRegistry(ctx context.Context, registry * if registry.TrustLevel != "" { update.SetTrustLevel(entskillregistry.TrustLevel(registry.TrustLevel)) } - if registry.AuthToken != "" { - update.SetAuthToken(registry.AuthToken) - } - if registry.ResolvePath != "" { - update.SetResolvePath(registry.ResolvePath) - } + update.SetAuthToken(registry.AuthToken) + update.SetResolvePath(registry.ResolvePath) if registry.Status != "" { update.SetStatus(entskillregistry.Status(registry.Status)) } - if len(registry.PinnedHashes) > 0 { + if registry.PinnedHashes != nil { b, _ := json.Marshal(registry.PinnedHashes) update.SetPinnedHashes(string(b)) } diff --git a/pkg/store/entadapter/skill_store.go b/pkg/store/entadapter/skill_store.go index 6c0187544..9fc43148b 100644 --- a/pkg/store/entadapter/skill_store.go +++ b/pkg/store/entadapter/skill_store.go @@ -378,6 +378,31 @@ func (s *SkillStore) UpdateSkillVersion(ctx context.Context, version *store.Skil return nil } +// DeleteSkillVersion hard-deletes a skill version record. +// Only draft versions may be deleted; attempting to delete a published, +// deprecated, or archived version returns an error. +func (s *SkillStore) DeleteSkillVersion(ctx context.Context, id string) error { + uid, err := parseUUID(id) + if err != nil { + return err + } + + // Fetch the version first to check its status. + e, err := s.client.SkillVersion.Get(ctx, uid) + if err != nil { + return mapError(err) + } + + if e.Status != entskillversion.StatusDraft { + return fmt.Errorf("cannot delete skill version in %q status: only draft versions can be deleted", e.Status) + } + + if err := s.client.SkillVersion.DeleteOneID(uid).Exec(ctx); err != nil { + return mapError(err) + } + return nil +} + // ResolveSkillVersion resolves a version constraint to a specific published version. func (s *SkillStore) ResolveSkillVersion(ctx context.Context, skillID, constraint string) (*store.SkillVersion, error) { // Content-addressed lookup diff --git a/pkg/store/entadapter/skill_store_test.go b/pkg/store/entadapter/skill_store_test.go index 126df643d..80719a398 100644 --- a/pkg/store/entadapter/skill_store_test.go +++ b/pkg/store/entadapter/skill_store_test.go @@ -449,3 +449,60 @@ func TestSkillStore_UniqueSlugPerScope(t *testing.T) { }) assert.NoError(t, err) } + +func TestSkillStore_DeleteSkillVersion(t *testing.T) { + cs := newTestCompositeStore(t) + ctx := context.Background() + + skillID := uuid.New().String() + require.NoError(t, cs.CreateSkill(ctx, &store.Skill{ + ID: skillID, + Name: "delete-version-test", + Slug: "delete-version-test", + Scope: "global", + Status: "active", + Visibility: "private", + })) + + // Create a draft version and delete it successfully. + draftID := uuid.New().String() + require.NoError(t, cs.CreateSkillVersion(ctx, &store.SkillVersion{ + ID: draftID, + SkillID: skillID, + Version: "1.0.0", + Status: store.SkillVersionStatusDraft, + })) + + err := cs.DeleteSkillVersion(ctx, draftID) + require.NoError(t, err) + + // Verify the version is gone. + _, err = cs.GetSkillVersion(ctx, draftID) + assert.ErrorIs(t, err, store.ErrNotFound) + + // Create a published version and verify delete is rejected. + pubID := uuid.New().String() + require.NoError(t, cs.CreateSkillVersion(ctx, &store.SkillVersion{ + ID: pubID, + SkillID: skillID, + Version: "2.0.0", + Status: store.SkillVersionStatusPublished, + })) + + err = cs.DeleteSkillVersion(ctx, pubID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "only draft versions can be deleted") + + // Verify the published version still exists. + got, err := cs.GetSkillVersion(ctx, pubID) + require.NoError(t, err) + assert.Equal(t, "2.0.0", got.Version) +} + +func TestSkillStore_DeleteSkillVersion_NotFound(t *testing.T) { + cs := newTestCompositeStore(t) + ctx := context.Background() + + err := cs.DeleteSkillVersion(ctx, uuid.New().String()) + assert.ErrorIs(t, err, store.ErrNotFound) +} diff --git a/pkg/store/entadapter/template_store.go b/pkg/store/entadapter/template_store.go index 73cf58580..727aa454c 100644 --- a/pkg/store/entadapter/template_store.go +++ b/pkg/store/entadapter/template_store.go @@ -89,6 +89,7 @@ func entTemplateRowToStore(e *ent.Template) *store.Template { StorageBucket: e.StorageBucket, StoragePath: e.StoragePath, BaseTemplate: e.BaseTemplate, + SourceURL: e.SourceURL, Status: string(e.Status), OwnerID: e.OwnerID, CreatedBy: e.CreatedBy, @@ -136,6 +137,7 @@ func (s *TemplateStore) CreateTemplate(ctx context.Context, template *store.Temp SetStoragePath(template.StoragePath). SetFiles(marshalJSONString(template.Files)). SetBaseTemplate(template.BaseTemplate). + SetSourceURL(template.SourceURL). SetStatus(enttemplate.Status(template.Status)). SetOwnerID(template.OwnerID). SetCreatedBy(template.CreatedBy). @@ -216,6 +218,7 @@ func (s *TemplateStore) UpdateTemplate(ctx context.Context, template *store.Temp SetStoragePath(template.StoragePath). SetFiles(marshalJSONString(template.Files)). SetBaseTemplate(template.BaseTemplate). + SetSourceURL(template.SourceURL). SetStatus(enttemplate.Status(template.Status)). SetOwnerID(template.OwnerID). SetUpdatedBy(template.UpdatedBy). @@ -357,6 +360,7 @@ func entHarnessConfigToStore(e *ent.HarnessConfig) *store.HarnessConfig { StorageURI: e.StorageURI, StorageBucket: e.StorageBucket, StoragePath: e.StoragePath, + SourceURL: e.SourceURL, Status: string(e.Status), OwnerID: e.OwnerID, CreatedBy: e.CreatedBy, @@ -400,6 +404,7 @@ func (s *TemplateStore) CreateHarnessConfig(ctx context.Context, hc *store.Harne SetStorageBucket(hc.StorageBucket). SetStoragePath(hc.StoragePath). SetFiles(marshalJSONString(hc.Files)). + SetSourceURL(hc.SourceURL). SetStatus(entharnessconfig.Status(hc.Status)). SetOwnerID(hc.OwnerID). SetCreatedBy(hc.CreatedBy). @@ -468,6 +473,7 @@ func (s *TemplateStore) UpdateHarnessConfig(ctx context.Context, hc *store.Harne SetStorageBucket(hc.StorageBucket). SetStoragePath(hc.StoragePath). SetFiles(marshalJSONString(hc.Files)). + SetSourceURL(hc.SourceURL). SetStatus(entharnessconfig.Status(hc.Status)). SetOwnerID(hc.OwnerID). SetUpdatedBy(hc.UpdatedBy). diff --git a/pkg/store/models.go b/pkg/store/models.go index a1595cfa5..2ffb6a86f 100644 --- a/pkg/store/models.go +++ b/pkg/store/models.go @@ -481,6 +481,7 @@ type Template struct { OwnerID string `json:"ownerId,omitempty"` CreatedBy string `json:"createdBy,omitempty"` UpdatedBy string `json:"updatedBy,omitempty"` + SourceURL string `json:"sourceUrl,omitempty"` Visibility string `json:"visibility"` // private, project, public // Timestamps @@ -574,6 +575,7 @@ type HarnessConfig struct { OwnerID string `json:"ownerId,omitempty"` CreatedBy string `json:"createdBy,omitempty"` UpdatedBy string `json:"updatedBy,omitempty"` + SourceURL string `json:"sourceUrl,omitempty"` Visibility string `json:"visibility"` // private, project, public // Timestamps diff --git a/pkg/store/store.go b/pkg/store/store.go index 84bb63043..468bf05d7 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -1257,6 +1257,7 @@ type SkillStore interface { GetSkillVersionByNumber(ctx context.Context, skillID, version string) (*SkillVersion, error) ListSkillVersions(ctx context.Context, skillID string, opts ListOptions) (*ListResult[SkillVersion], error) UpdateSkillVersion(ctx context.Context, version *SkillVersion) error + DeleteSkillVersion(ctx context.Context, id string) error ResolveSkillVersion(ctx context.Context, skillID, constraint string) (*SkillVersion, error) diff --git a/pkg/templatecache/resolver_test.go b/pkg/templatecache/resolver_test.go index 332fb6018..61fdc4604 100644 --- a/pkg/templatecache/resolver_test.go +++ b/pkg/templatecache/resolver_test.go @@ -56,6 +56,10 @@ func (m *mockHarnessConfigService) Update(ctx context.Context, id string, req *h func (m *mockHarnessConfigService) Delete(ctx context.Context, id string) error { return nil } +func (m *mockHarnessConfigService) Reimport(ctx context.Context, id string, sourceURL string) (*hubclient.ReimportHarnessConfigResponse, error) { + return nil, nil +} + func (m *mockHarnessConfigService) RequestUploadURLs(ctx context.Context, id string, files []hubclient.FileUploadRequest) (*hubclient.UploadResponse, error) { return nil, nil } diff --git a/web/package-lock.json b/web/package-lock.json index f0b6bbc2d..0884f7eee 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -29,11 +29,14 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", - "dompurify": "^3.4.9", + "chart.js": "^4.4.0", + "dompurify": "^3.4.11", + "js-yaml": "^4.1.0", "lit": "^3.1.2", "marked": "^17.0.5" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^20.11.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", @@ -877,6 +880,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", @@ -1476,6 +1485,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -1808,7 +1824,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-union": { @@ -1895,6 +1910,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2011,9 +2038,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz", - "integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2677,7 +2704,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, "funding": [ { "type": "github", diff --git a/web/package.json b/web/package.json index 9a35c6f0b..40d08ffc6 100644 --- a/web/package.json +++ b/web/package.json @@ -41,11 +41,14 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", - "dompurify": "^3.4.9", + "chart.js": "^4.4.0", + "dompurify": "^3.4.11", + "js-yaml": "^4.1.0", "lit": "^3.1.2", "marked": "^17.0.5" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^20.11.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", diff --git a/web/scripts/copy-shoelace-icons.mjs b/web/scripts/copy-shoelace-icons.mjs index 2d2a0b9f6..52bf14c30 100644 --- a/web/scripts/copy-shoelace-icons.mjs +++ b/web/scripts/copy-shoelace-icons.mjs @@ -89,6 +89,7 @@ const USED_ICONS = [ 'funnel', 'gear', 'github', + 'graph-up', 'grid-3x3-gap', 'google', 'hdd-rack', diff --git a/web/src/client/main.ts b/web/src/client/main.ts index 0102826ad..bb5c2f8ca 100644 --- a/web/src/client/main.ts +++ b/web/src/client/main.ts @@ -143,6 +143,8 @@ const ROUTES: RouteConfig[] = [ { pattern: /^\/admin\/users$/, tag: 'scion-page-admin-users', load: () => import('../components/pages/admin-users.js') }, { pattern: /^\/admin\/groups$/, tag: 'scion-page-admin-groups', load: () => import('../components/pages/admin-groups.js') }, { pattern: /^\/admin\/groups\/[^/]+$/, tag: 'scion-page-admin-group-detail', load: () => import('../components/pages/admin-group-detail.js') }, + { pattern: /^\/metrics$/, tag: 'scion-page-metrics', load: () => import('../components/pages/metrics-dashboard.js') }, + { pattern: /^\/admin\/metrics$/, tag: 'scion-page-metrics', load: () => import('../components/pages/metrics-dashboard.js') }, { pattern: /^\/admin\/maintenance$/, tag: 'scion-page-admin-maintenance', load: () => import('../components/pages/admin-maintenance.js') }, { pattern: /^\/admin\/server-config$/, tag: 'scion-page-admin-server-config', load: () => import('../components/pages/admin-server-config.js') }, { pattern: /^\/settings$/, tag: 'scion-page-settings', load: () => import('../components/pages/settings.js') }, @@ -161,6 +163,7 @@ const ROUTES: RouteConfig[] = [ { pattern: /^\/projects\/[^/]+\/templates\/[^/]+$/, tag: 'scion-page-template-detail', load: () => import('../components/pages/template-detail.js') }, { pattern: /^\/projects\/[^/]+\/harness-configs\/[^/]+$/, tag: 'scion-page-harness-config-detail', load: () => import('../components/pages/harness-config-detail.js') }, { pattern: /^\/projects\/[^/]+\/schedules$/, tag: 'scion-page-project-schedules', load: () => import('../components/pages/project-schedules.js') }, + { pattern: /^\/projects\/[^/]+\/metrics$/, tag: 'scion-page-metrics', load: () => import('../components/pages/metrics-dashboard.js') }, { pattern: /^\/projects\/[^/]+$/, tag: 'scion-page-project-detail', load: () => import('../components/pages/project-detail.js') }, { pattern: /^\/agents\/new$/, tag: 'scion-page-agent-create', load: () => import('../components/pages/agent-create.js') }, { pattern: /^\/agents\/[^/]+\/configure$/, tag: 'scion-page-agent-configure', load: () => import('../components/pages/agent-configure.js') }, diff --git a/web/src/components/app-shell.ts b/web/src/components/app-shell.ts index dbe36b5e1..649ed707f 100644 --- a/web/src/components/app-shell.ts +++ b/web/src/components/app-shell.ts @@ -48,6 +48,7 @@ const PAGE_TITLES: Record = { '/admin/users': 'Users', '/admin/groups': 'Groups', '/admin/server-config': 'Server Config', + '/metrics': 'Metrics', '/admin/skill-registries': 'Skill Registries', '/skills': 'Skills', '/github-app/installed': 'GitHub App Setup', @@ -321,6 +322,9 @@ export class ScionApp extends LitElement { if (this.currentPath.match(/^\/projects\/[^/]+\/schedules$/)) { return 'Schedules'; } + if (this.currentPath.match(/^\/projects\/[^/]+\/metrics$/)) { + return 'Project Metrics'; + } if (this.currentPath.match(/^\/projects\/[^/]+\/templates\/[^/]+$/)) { return 'Template'; } diff --git a/web/src/components/pages/agent-detail.ts b/web/src/components/pages/agent-detail.ts index 7695fba85..95464136b 100644 --- a/web/src/components/pages/agent-detail.ts +++ b/web/src/components/pages/agent-detail.ts @@ -879,6 +879,50 @@ export class ScionPageAgentDetail extends LitElement { } } + private shouldShowCaptureAuth(agent: Agent | null): boolean { + if (!agent) return false; + if (agent.phase !== 'running') return false; + const isNoAuth = agent.appliedConfig?.noAuth === true || agent.harnessAuth === 'none'; + return isNoAuth && !!agent.resolvedHarness; + } + + private async handleCaptureAuth(): Promise { + if (!this.agent) return; + this.actionLoading = { ...this.actionLoading, 'capture-auth': true }; + try { + const response = await apiFetch(`/api/v1/agents/${this.agent.id}/exec`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: ['python3', '/home/scion/.scion/harness/capture_auth.py'], + timeout: 60, + }), + }); + + if (!response.ok) { + const msg = await extractApiError(response, 'Failed to run capture auth'); + alert(msg); + return; + } + + const result = await response.json() as { output: string; exitCode: number }; + + if (result.exitCode === 0) { + alert(`Credentials captured successfully.\n\n${result.output}`); + await this.fetchAndMergeAgent(); + } else if (result.exitCode === 2) { + alert(`No credentials found yet.\n\nAuthenticate first (e.g., run 'agy' inside the container), then try again.\n\n${result.output}`); + } else { + alert(`Capture failed (exit ${result.exitCode}).\n\n${result.output}`); + } + } catch (err) { + console.error('Failed to capture auth:', err); + alert(err instanceof Error ? err.message : 'Failed to capture auth'); + } finally { + this.actionLoading = { ...this.actionLoading, 'capture-auth': false }; + } + } + private backgroundRefresh(): void { this.fetchAndMergeAgent().catch((err) => { console.warn('Background refresh failed:', err); @@ -942,26 +986,23 @@ export class ScionPageAgentDetail extends LitElement { Status - ${this.agent.cloudLogging ? html`Logs` : nothing} + Logs Messages Configuration ${this.renderStatusTab()} - ${this.agent.cloudLogging - ? html` - - - - ` - : nothing} + + + ` : nothing} + ${this.shouldShowCaptureAuth(agent) + ? html` + + this.handleCaptureAuth()} + > + + Capture Auth + + + ` + : nothing} ${isAgentRunning(agent) ? html` diff --git a/web/src/components/pages/harness-config-detail.ts b/web/src/components/pages/harness-config-detail.ts index 825bbd6eb..b69b77caf 100644 --- a/web/src/components/pages/harness-config-detail.ts +++ b/web/src/components/pages/harness-config-detail.ts @@ -97,6 +97,27 @@ export class ScionPageHarnessConfigDetail extends LitElement { @state() private buildError = ''; + @state() + private reimportRunning = false; + + @state() + private reimportStatus = ''; + + @state() + private reimportError = ''; + + @state() + private deleteDialogOpen = false; + + @state() + private deleteInProgress = false; + + @state() + private deleteFiles = false; + + @state() + private deleteError = ''; + private fileBrowserDataSource: FileBrowserDataSource | null = null; private fileEditorDataSource: FileEditorDataSource | null = null; private buildPollTimer: ReturnType | null = null; @@ -224,12 +245,92 @@ export class ScionPageHarnessConfigDetail extends LitElement { .build-status-badge.completed { color: var(--sl-color-success-600); } .build-status-badge.failed { color: var(--sl-color-danger-600); } + .source-url { + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + .source-url a { + color: var(--sl-color-primary-600); + text-decoration: none; + } + .source-url a:hover { + text-decoration: underline; + } + .reimport-status { + font-size: 0.85rem; + margin-top: 0.5rem; + } + .reimport-status.success { color: var(--sl-color-success-600); } + .reimport-status.error { color: var(--sl-color-danger-600); } + .build-error { color: var(--sl-color-danger-600); font-size: 0.85rem; margin-top: 0.5rem; } + .image-section { + margin-top: 1.5rem; + } + .image-section h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; + } + .image-info { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--sl-color-neutral-50); + border: 1px solid var(--sl-color-neutral-200); + border-radius: var(--sl-border-radius-medium); + flex-wrap: wrap; + } + .image-path { + font-family: var(--sl-font-mono); + font-size: 0.8125rem; + word-break: break-all; + flex: 1; + min-width: 0; + } + .image-type-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: var(--sl-border-radius-pill); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + white-space: nowrap; + } + .image-type-badge.local { + background: var(--sl-color-neutral-100); + color: var(--sl-color-neutral-700); + } + .image-type-badge.remote { + background: var(--sl-color-primary-50); + color: var(--sl-color-primary-700); + } + .image-meta { + font-size: 0.75rem; + color: var(--sl-color-neutral-500); + } + + .dialog-warning { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--sl-color-danger-600); + margin-top: 0.75rem; + } + .dialog-error { + color: var(--sl-color-danger-600); + font-size: 0.8125rem; + margin-top: 0.5rem; + } + .error-state, .loading-state { text-align: center; @@ -369,7 +470,7 @@ export class ScionPageHarnessConfigDetail extends LitElement { )} - ${this.renderHeader()} ${this.renderFilesSection()} ${this.renderBuildDialog()} ${this.renderBuildLog()} + ${this.renderHeader()} ${this.renderFilesSection()} ${this.renderImageSection()} ${this.renderBuildDialog()} ${this.renderBuildLog()} ${this.renderDeleteDialog()} `; } @@ -384,19 +485,31 @@ export class ScionPageHarnessConfigDetail extends LitElement { >

${hc.displayName || hc.name}

${hc.harness ? html`${hc.harness}` : ''} - ${this.hasDockerfile ? html` -
+
+ ${hc.sourceUrl ? html` - - ${this.buildRunning ? 'Building...' : 'Build Image'} + + Refresh from Source -
- ` : nothing} + ` : nothing} + ${can(hc._capabilities, 'delete') || can(hc._capabilities, 'manage') ? html` + { this.deleteDialogOpen = true; this.deleteError = ''; }} + > + + Delete + + ` : nothing} +
${hc.description ? html`

${hc.description}

` : ''}
@@ -408,7 +521,14 @@ export class ScionPageHarnessConfigDetail extends LitElement { ` : ''} + ${hc.sourceUrl + ? html`Source: + ${hc.sourceUrl} + ` + : ''}
+ ${this.reimportStatus ? html`

${this.reimportStatus}

` : ''} + ${this.reimportError ? html`

${this.reimportError}

` : ''} `; } @@ -450,6 +570,84 @@ export class ScionPageHarnessConfigDetail extends LitElement { `; } + private isRemoteImage(image: string): boolean { + return image.includes('/') && (image.includes('.') || image.includes(':')); + } + + private renderImageSection() { + const hc = this.harnessConfig!; + const image = hc.config?.image; + const showSection = image || this.hasDockerfile; + if (!showSection) return nothing; + + const isRemote = image ? this.isRemoteImage(image) : false; + + return html` +
+

Image

+
+ ${image ? html` + + ${isRemote ? 'Remote' : 'Local'} + + ${image} + ` : html` + No image configured + `} + ${hc.updated ? html` + Last updated: ${new Date(hc.updated).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })} + ` : nothing} + ${this.hasDockerfile ? html` + + + ${this.buildRunning ? 'Building...' : 'Build Image'} + + ` : nothing} +
+
+ `; + } + + // ── Refresh from Source ── + + private async startReimport(): Promise { + if (!this.harnessConfig?.id) return; + this.reimportRunning = true; + this.reimportStatus = ''; + this.reimportError = ''; + + try { + const response = await apiFetch( + `/api/v1/harness-configs/${this.harnessConfig.id}/reimport`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); + + if (!response.ok) { + const errMsg = await extractApiError(response, `HTTP ${response.status}`); + this.reimportError = errMsg; + return; + } + + const result = await response.json(); + const count = result?.count ?? result?.harnessConfigs?.length ?? 0; + this.reimportStatus = `Refreshed successfully (${count} config${count !== 1 ? 's' : ''} updated).`; + await this.loadHarnessConfig(); + } catch (err) { + this.reimportError = err instanceof Error ? err.message : 'Failed to refresh from source'; + } finally { + this.reimportRunning = false; + } + } + // ── Build Image ── private openBuildDialog(): void { @@ -622,6 +820,86 @@ export class ScionPageHarnessConfigDetail extends LitElement { `; } + // ── Delete ── + + private renderDeleteDialog() { + if (!this.deleteDialogOpen || !this.harnessConfig) return nothing; + const hc = this.harnessConfig; + return html` + { + if (this.deleteInProgress) e.preventDefault(); + else this.deleteDialogOpen = false; + }} + > +

+ Are you sure you want to delete + ${hc.displayName || hc.name}? +

+ { + this.deleteFiles = (e.target as HTMLInputElement).checked; + }} + > + Also delete stored files + +
+ + This action cannot be undone. +
+ ${this.deleteError ? html`
${this.deleteError}
` : nothing} +
+ { this.deleteDialogOpen = false; }} + > + Cancel + + this.confirmDelete()} + > + Delete + +
+
+ `; + } + + private async confirmDelete(): Promise { + if (!this.harnessConfig) return; + this.deleteInProgress = true; + this.deleteError = ''; + try { + const params = new URLSearchParams({ deleteFiles: String(this.deleteFiles) }); + const response = await apiFetch( + `/api/v1/harness-configs/${this.harnessConfig.id}?${params.toString()}`, + { method: 'DELETE' } + ); + if (!response.ok) { + throw new Error( + await extractApiError(response, `Failed to delete: HTTP ${response.status}`) + ); + } + this.deleteDialogOpen = false; + // Navigate back to the list view + const backLink = this.backLinks()[0]; + window.location.href = backLink.href; + } catch (err) { + this.deleteError = err instanceof Error ? err.message : 'Delete failed'; + } finally { + this.deleteInProgress = false; + } + } + override disconnectedCallback(): void { super.disconnectedCallback(); this.stopBuildPolling(); diff --git a/web/src/components/pages/metrics-dashboard.ts b/web/src/components/pages/metrics-dashboard.ts new file mode 100644 index 000000000..a95d82433 --- /dev/null +++ b/web/src/components/pages/metrics-dashboard.ts @@ -0,0 +1,646 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { Chart, registerables } from 'chart.js'; + +import { apiFetch, extractApiError } from '../../client/api.js'; + +Chart.register(...registerables); + +interface TimeSeriesPoint { + timestamp: string; + value: number; +} + +interface LabeledTimeSeries { + label: string; + points: TimeSeriesPoint[]; +} + +interface SummaryData { + periodDays: number; + totalSessions: number; + totalApiCalls: number; + totalTokens: number; + uniqueAgents: number; +} + +interface SessionsData { + periodDays: number; + dailyCounts: TimeSeriesPoint[]; + activeAgents: TimeSeriesPoint[]; +} + +interface ModelCallsData { + periodDays: number; + byModel: LabeledTimeSeries[]; + byHarness: LabeledTimeSeries[]; +} + +interface TokensData { + periodDays: number; + input: LabeledTimeSeries[]; + output: LabeledTimeSeries[]; +} + +const CHART_COLORS = [ + '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', + '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1', +]; + +@customElement('scion-page-metrics') +export class ScionPageMetrics extends LitElement { + @property({ type: String }) + projectId = ''; + + @state() private loading = true; + @state() private error: string | null = null; + @state() private activeTab = 'summary'; + @state() private periodDays = 7; + + @state() private summary: SummaryData | null = null; + @state() private sessions: SessionsData | null = null; + @state() private modelCalls: ModelCallsData | null = null; + @state() private tokens: TokensData | null = null; + + private charts: Map = new Map(); + + static override styles = css` + :host { + display: block; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + } + + .header-left { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .header sl-icon { + color: var(--scion-primary, #3b82f6); + font-size: 1.5rem; + } + + .header h1 { + font-size: 1.5rem; + font-weight: 700; + color: var(--scion-text, #1e293b); + margin: 0; + } + + .period-selector { + display: flex; + gap: 0.5rem; + } + + .period-btn { + padding: 0.375rem 0.75rem; + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius, 0.5rem); + background: var(--scion-surface, #ffffff); + color: var(--scion-text-muted, #64748b); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + } + + .period-btn:hover { + border-color: var(--scion-primary, #3b82f6); + color: var(--scion-primary, #3b82f6); + } + + .period-btn.active { + background: var(--scion-primary, #3b82f6); + border-color: var(--scion-primary, #3b82f6); + color: #ffffff; + } + + sl-tab-group { + --indicator-color: var(--scion-primary, #3b82f6); + } + + sl-tab-group::part(base) { + background: transparent; + } + + sl-tab::part(base) { + font-size: 0.875rem; + padding: 0.75rem 1rem; + } + + .section { + background: var(--scion-surface, #ffffff); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius-lg, 0.75rem); + padding: 1.5rem; + margin-bottom: 1.5rem; + } + + .stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; + } + + .stat-card { + display: flex; + flex-direction: column; + padding: 1.25rem; + background: var(--scion-bg-subtle, #f1f5f9); + border-radius: var(--scion-radius, 0.5rem); + } + + .stat-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--scion-text-muted, #64748b); + margin-bottom: 0.375rem; + } + + .stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--scion-text, #1e293b); + } + + .chart-container { + position: relative; + width: 100%; + height: 320px; + } + + .chart-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + } + + @media (max-width: 900px) { + .chart-row { + grid-template-columns: 1fr; + } + } + + .chart-section-title { + font-size: 0.9375rem; + font-weight: 600; + color: var(--scion-text, #1e293b); + margin: 0 0 1rem 0; + } + + .empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--scion-text-muted, #64748b); + } + + .empty-state sl-icon { + font-size: 2.5rem; + margin-bottom: 1rem; + display: block; + } + `; + + override connectedCallback(): void { + super.connectedCallback(); + // Parse projectId from URL if not set as property (same pattern as project-detail.ts) + if (!this.projectId && typeof window !== 'undefined') { + const match = window.location.pathname.match(/\/projects\/([^/]+)\/metrics/); + if (match) this.projectId = match[1]; + } + void this.loadView('summary'); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.destroyAllCharts(); + } + + private destroyAllCharts(): void { + for (const chart of this.charts.values()) { + chart.destroy(); + } + this.charts.clear(); + } + + private async loadView(view: string): Promise { + this.loading = true; + this.error = null; + + try { + const basePath = this.projectId + ? `/api/v1/projects/${this.projectId}/metrics` + : `/api/v1/metrics/`; + const response = await apiFetch( + `${basePath}?view=${view}&period=${this.periodDays}` + ); + + if (!response.ok) { + throw new Error(await extractApiError(response, `HTTP ${response.status}`)); + } + + const data = await response.json(); + + switch (view) { + case 'summary': + this.summary = data as SummaryData; + break; + case 'sessions': + this.sessions = data as SessionsData; + break; + case 'model-calls': + this.modelCalls = data as ModelCallsData; + break; + case 'tokens': + this.tokens = data as TokensData; + break; + } + } catch (err) { + this.error = err instanceof Error ? err.message : String(err); + } finally { + this.loading = false; + } + } + + private handleTabChange(e: CustomEvent): void { + const name = (e.detail as { name: string }).name; + this.activeTab = name; + this.destroyAllCharts(); + + const viewMap: Record = { + summary: 'summary', + sessions: 'sessions', + 'model-calls': 'model-calls', + tokens: 'tokens', + }; + + void this.loadView(viewMap[name] ?? 'summary'); + } + + private handlePeriodChange(days: number): void { + this.periodDays = days; + this.destroyAllCharts(); + void this.loadView(this.activeTab === 'model-calls' ? 'model-calls' : this.activeTab); + } + + private formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); + } + + private static readonly CHART_PROPERTIES = new Set([ + 'sessions', 'modelCalls', 'tokens', 'activeTab', 'loading', + ]); + + override updated(changedProperties: Map): void { + super.updated(changedProperties); + for (const key of changedProperties.keys()) { + if (ScionPageMetrics.CHART_PROPERTIES.has(key as string)) { + this.updateCharts(); + break; + } + } + } + + private updateCharts(): void { + switch (this.activeTab) { + case 'sessions': + if (this.sessions) { + if (this.sessions.dailyCounts?.length) { + const sorted = this.sortPointsByDate(this.sessions.dailyCounts); + this.renderChart('chart-sessions', 'bar', sorted.map(p => p.timestamp), [ + { + label: 'Sessions', + data: sorted.map(p => p.value), + backgroundColor: CHART_COLORS[0], + }, + ]); + } + if (this.sessions.activeAgents?.length) { + const sorted = this.sortPointsByDate(this.sessions.activeAgents); + this.renderChart('chart-agents', 'line', sorted.map(p => p.timestamp), [ + { + label: 'Active Agents', + data: sorted.map(p => p.value), + borderColor: CHART_COLORS[1], + backgroundColor: CHART_COLORS[1] + '33', + }, + ]); + } + } + break; + case 'model-calls': + if (this.modelCalls) { + if (this.modelCalls.byModel?.length) { + const allDates = this.collectDates(this.modelCalls.byModel); + this.renderChart( + 'chart-model-calls', + 'bar', + allDates, + this.modelCalls.byModel.map((series, i) => ({ + label: series.label, + data: this.alignToDateAxis(series.points, allDates), + backgroundColor: CHART_COLORS[i % CHART_COLORS.length], + })) + ); + } + if (this.modelCalls.byHarness?.length) { + const allDates = this.collectDates(this.modelCalls.byHarness); + this.renderChart( + 'chart-harness-calls', + 'bar', + allDates, + this.modelCalls.byHarness.map((series, i) => ({ + label: series.label, + data: this.alignToDateAxis(series.points, allDates), + backgroundColor: CHART_COLORS[(i + 3) % CHART_COLORS.length], + })) + ); + } + } + break; + case 'tokens': + if (this.tokens) { + if (this.tokens.input?.length) { + const allDates = this.collectDates(this.tokens.input); + this.renderChart( + 'chart-tokens-input', + 'bar', + allDates, + this.tokens.input.map((series, i) => ({ + label: series.label, + data: this.alignToDateAxis(series.points, allDates), + backgroundColor: CHART_COLORS[i % CHART_COLORS.length], + })) + ); + } + if (this.tokens.output?.length) { + const allDates = this.collectDates(this.tokens.output); + this.renderChart( + 'chart-tokens-output', + 'bar', + allDates, + this.tokens.output.map((series, i) => ({ + label: series.label, + data: this.alignToDateAxis(series.points, allDates), + backgroundColor: CHART_COLORS[(i + 3) % CHART_COLORS.length], + })) + ); + } + } + break; + } + } + + private renderChart( + canvasId: string, + type: 'bar' | 'line', + labels: string[], + datasets: { label: string; data: number[]; backgroundColor?: string; borderColor?: string }[] + ): void { + requestAnimationFrame(() => { + const canvas = this.shadowRoot?.getElementById(canvasId) as HTMLCanvasElement | null; + if (!canvas) return; + + const existing = this.charts.get(canvasId); + if (existing) { + if (existing.config.type === type) { + existing.data.labels = labels; + existing.data.datasets = datasets; + existing.update(); + return; + } + existing.destroy(); + } + + const chart = new Chart(canvas, { + type, + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { boxWidth: 12, padding: 16, font: { size: 12 } }, + }, + }, + scales: { + x: { + grid: { display: false }, + ticks: { font: { size: 11 } }, + }, + y: { + beginAtZero: true, + grid: { color: '#f1f5f9' }, + ticks: { font: { size: 11 } }, + }, + }, + }, + }); + this.charts.set(canvasId, chart); + }); + } + + private sortPointsByDate(points: TimeSeriesPoint[]): TimeSeriesPoint[] { + return [...points].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + } + + override render() { + return html` +
+
+ +

${this.projectId ? 'Project Metrics' : 'Metrics Dashboard'}

+
+
+ ${[7, 14, 30].map( + d => html` + + ` + )} +
+
+ + ${this.error + ? html` + + + ${this.error} + + ` + : nothing} + + + Summary + Sessions & Users + Model Calls + Token Usage + + ${this.renderSummaryTab()} + ${this.renderSessionsTab()} + ${this.renderModelCallsTab()} + ${this.renderTokensTab()} + + `; + } + + private renderSummaryTab() { + if (this.loading) return html``; + if (!this.summary) return this.renderEmptyState(); + + const s = this.summary; + return html` +
+
+ Total Sessions + ${this.formatNumber(s.totalSessions)} +
+
+ API Calls + ${this.formatNumber(s.totalApiCalls)} +
+
+ Total Tokens + ${this.formatNumber(s.totalTokens)} +
+
+ Unique Agents + ${this.formatNumber(s.uniqueAgents)} +
+
+ `; + } + + private renderSessionsTab() { + if (this.loading) return html``; + if (!this.sessions) return this.renderEmptyState(); + + const s = this.sessions; + + return html` +
+
+

Daily Sessions

+
+ +
+
+
+

Active Agents per Day

+
+ +
+
+
+ `; + } + + private renderModelCallsTab() { + if (this.loading) return html``; + if (!this.modelCalls) return this.renderEmptyState(); + + const mc = this.modelCalls; + + return html` +
+
+

API Calls by Model

+
+ +
+
+
+

API Calls by Harness

+
+ +
+
+
+ `; + } + + private renderTokensTab() { + if (this.loading) return html``; + if (!this.tokens) return this.renderEmptyState(); + + const t = this.tokens; + + return html` +
+
+

Input Tokens by Model

+
+ +
+
+
+

Output Tokens by Model

+
+ +
+
+
+ `; + } + + private renderEmptyState() { + return html` +
+ +

No metrics data available for this period.

+

Metrics are collected from agent telemetry via Google Cloud Monitoring.

+
+ `; + } + + private collectDates(series: LabeledTimeSeries[]): string[] { + const dateSet = new Set(); + for (const s of series) { + for (const p of s.points) { + dateSet.add(p.timestamp); + } + } + return [...dateSet].sort(); + } + + private alignToDateAxis(points: TimeSeriesPoint[], dates: string[]): number[] { + const map = new Map(); + for (const p of points) { + map.set(p.timestamp, p.value); + } + return dates.map(d => map.get(d) ?? 0); + } +} diff --git a/web/src/components/pages/project-detail.ts b/web/src/components/pages/project-detail.ts index 8c3109dea..59751e705 100644 --- a/web/src/components/pages/project-detail.ts +++ b/web/src/components/pages/project-detail.ts @@ -143,6 +143,18 @@ export class ScionPageProjectDetail extends LitElement { error?: string; } | null = null; + /** + * Metrics summary for the project (null = not loaded or unavailable) + */ + @state() + private metricsSummary: { + sessionsCount24h: number; + apiCalls24h: number; + tokenUsage24h: number; + activeAgents24h: number; + periodLabel: string; + } | null = null; + /** * Whether the messages section is expanded (lazy-load trigger) */ @@ -841,6 +853,9 @@ export class ScionPageProjectDetail extends LitElement { this.getTabDataSource(this.project.sharedDirs[0].name); } + // Fetch metrics summary (non-blocking, gracefully degrades) + void this.loadMetricsSummary(); + // Auto-discover GitHub App installation if project has a GitHub remote but no installation if (this.project && this.project.gitRemote && /github\.com[/:]/.test(this.project.gitRemote) && this.project.githubInstallationId == null) { void this.autoDiscoverGitHubApp(); @@ -853,6 +868,32 @@ export class ScionPageProjectDetail extends LitElement { } } + private async loadMetricsSummary(): Promise { + try { + const res = await apiFetch(`/api/v1/projects/${this.projectId}/metrics-summary`); + if (!res.ok) { + this.metricsSummary = null; + return; + } + const data = await res.json(); + // If metrics service is unavailable, the backend returns {available: false} + if (data && data.available === false) { + this.metricsSummary = null; + return; + } + this.metricsSummary = data; + } catch { + this.metricsSummary = null; + } + } + + private formatTokenCount(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); + } + private backgroundRefresh(): void { this.fetchAndMergeAgents().catch(err => { console.warn('Background refresh failed:', err); @@ -1289,6 +1330,12 @@ export class ScionPageProjectDetail extends LitElement { ` : nothing} + + + + Metrics + + ${canAny(this.project?._capabilities, 'update', 'delete', 'manage') ? html` @@ -1327,6 +1374,35 @@ export class ScionPageProjectDetail extends LitElement { + ${this.metricsSummary ? html` + + ` : nothing} + ${this.pullResult ? html` ; + if (!parsed || typeof parsed !== 'object') return null; + + const result: SkillFrontmatter = {}; + + if (typeof parsed.name === 'string') result.name = parsed.name; + if (typeof parsed.description === 'string') result.description = parsed.description; + + if (Array.isArray(parsed.tags)) { + result.tags = parsed.tags.filter((t): t is string => typeof t === 'string'); + } else if (typeof parsed.tags === 'string') { + result.tags = parsed.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + } + + return result; + } catch { + return null; + } +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + @customElement('scion-page-skill-create') export class ScionPageSkillCreate extends LitElement { + /* --- capabilities --- */ @state() private loading = true; @state() private canCreate = false; - @state() private submitting = false; - @state() private error: string | null = null; + + /* --- SKILL.md content --- */ + @state() private skillMdContent = ''; + + /* --- metadata fields --- */ @state() private name = ''; @state() private description = ''; @state() private scope: 'global' | 'project' | 'user' = 'global'; @@ -40,6 +105,30 @@ export class ScionPageSkillCreate extends LitElement { @state() private visibility: 'private' | 'public' = 'private'; @state() private tagsInput = ''; + /* --- auto-populate tracking --- */ + private editedFields = new Set(); + private debounceTimer: ReturnType | null = null; + + /* --- additional files --- */ + @state() private additionalFiles: SelectedFile[] = []; + @state() private duplicateSkillMdWarning = false; + @state() private version = '1.0.0'; + + /* --- flow state --- */ + @state() private flowState: FlowState = 'form'; + @state() private error: string | null = null; + @state() private validationError: string | null = null; + @state() private createdSkillId: string | null = null; + + private fileInputRef: HTMLInputElement | null = null; + private skillMdInputRef: HTMLInputElement | null = null; + private redirectTimer: ReturnType | null = null; + + /* --- allFiles cache --- */ + private cachedAllFiles: SelectedFile[] | null = null; + private cachedSkillMdContent = ''; + private cachedAdditionalFiles: SelectedFile[] = []; + static override styles = css` :host { display: block; @@ -54,7 +143,6 @@ export class ScionPageSkillCreate extends LitElement { font-size: 0.875rem; margin-bottom: 1rem; } - .back-link:hover { color: var(--scion-primary, #3b82f6); } @@ -62,7 +150,6 @@ export class ScionPageSkillCreate extends LitElement { .page-header { margin-bottom: 1.5rem; } - .page-header h1 { font-size: 1.5rem; font-weight: 700; @@ -72,12 +159,10 @@ export class ScionPageSkillCreate extends LitElement { align-items: center; gap: 0.75rem; } - .page-header h1 sl-icon { color: var(--scion-primary, #3b82f6); font-size: 1.5rem; } - .page-header p { color: var(--scion-text-muted, #64748b); margin: 0; @@ -92,12 +177,33 @@ export class ScionPageSkillCreate extends LitElement { max-width: 640px; } + .section-divider { + border: none; + border-top: 1px solid var(--scion-border, #e2e8f0); + margin: 1.5rem 0; + } + + .section-header { + font-size: 0.9375rem; + font-weight: 600; + color: var(--scion-text, #1e293b); + margin: 0 0 1rem 0; + } + + .section-desc { + font-size: 0.8125rem; + color: var(--scion-text-muted, #64748b); + margin: -0.5rem 0 1rem 0; + } + .form-field { margin-bottom: 1.25rem; } .form-field label { - display: block; + display: flex; + align-items: center; + gap: 0.5rem; font-size: 0.875rem; font-weight: 600; color: var(--scion-text, #1e293b); @@ -117,6 +223,22 @@ export class ScionPageSkillCreate extends LitElement { width: 100%; } + .skillmd-textarea { + --sl-input-font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + --sl-input-font-size-medium: 0.8125rem; + } + + .upload-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + } + + .from-badge { + font-size: 0.6875rem; + } + .form-actions { display: flex; gap: 0.75rem; @@ -137,19 +259,34 @@ export class ScionPageSkillCreate extends LitElement { color: var(--sl-color-danger-700, #b91c1c); font-size: 0.875rem; } - .error-banner sl-icon { flex-shrink: 0; margin-top: 0.125rem; } + .warning-banner { + background: var(--sl-color-warning-50, #fffbeb); + border: 1px solid var(--sl-color-warning-200, #fde68a); + border-radius: var(--scion-radius, 0.5rem); + padding: 0.75rem 1rem; + margin-bottom: 1.25rem; + display: flex; + align-items: flex-start; + gap: 0.5rem; + color: var(--sl-color-warning-700, #b45309); + font-size: 0.875rem; + } + .warning-banner sl-icon { + flex-shrink: 0; + margin-top: 0.125rem; + } + .tag-chips { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-top: 0.5rem; } - .tag-chip { display: inline-flex; align-items: center; @@ -161,13 +298,163 @@ export class ScionPageSkillCreate extends LitElement { border-radius: 9999px; color: var(--scion-text, #1e293b); } + + /* --- drop zone --- */ + .drop-zone { + border: 2px dashed var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius, 0.5rem); + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 150ms ease; + color: var(--scion-text-muted, #64748b); + font-size: 0.875rem; + } + .drop-zone:hover, + .drop-zone.dragover { + border-color: var(--scion-primary, #3b82f6); + background: var(--sl-color-primary-50, #eff6ff); + color: var(--scion-primary, #3b82f6); + } + .drop-zone sl-icon { + font-size: 2rem; + display: block; + margin: 0 auto 0.5rem; + } + + /* --- file list --- */ + .file-list { + list-style: none; + padding: 0; + margin: 0.75rem 0 0 0; + display: flex; + flex-direction: column; + gap: 0.375rem; + } + .file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background: var(--scion-bg-subtle, #f1f5f9); + border-radius: var(--scion-radius, 0.5rem); + font-size: 0.875rem; + } + .file-info { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + } + .file-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .file-meta { + color: var(--scion-text-muted, #64748b); + font-size: 0.75rem; + flex-shrink: 0; + } + + .remove-btn { + cursor: pointer; + background: none; + border: none; + padding: 0.25rem; + color: var(--scion-text-muted, #64748b); + line-height: 1; + } + .remove-btn:hover { + color: var(--sl-color-danger-600, #dc2626); + } + + /* --- progress view --- */ + .progress-card { + background: var(--scion-surface, #ffffff); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--scion-radius-lg, 0.75rem); + padding: 1.5rem; + max-width: 640px; + } + .progress-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--scion-text, #1e293b); + margin: 0 0 1.25rem 0; + } + .progress-steps { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.25rem; + } + .progress-step { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.875rem; + color: var(--scion-text-muted, #64748b); + } + .progress-step.active { + color: var(--scion-primary, #3b82f6); + font-weight: 500; + } + .progress-step.done { + color: var(--sl-color-success-600, #16a34a); + } + .progress-step.error { + color: var(--sl-color-danger-600, #dc2626); + } + .step-label { + flex: 1; + } + .step-status { + font-size: 0.75rem; + } + + .progress-actions { + display: flex; + gap: 0.75rem; + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid var(--scion-border, #e2e8f0); + } + + .success-banner { + background: var(--sl-color-success-50, #f0fdf4); + border: 1px solid var(--sl-color-success-200, #bbf7d0); + border-radius: var(--scion-radius, 0.5rem); + padding: 1rem; + text-align: center; + color: var(--sl-color-success-700, #15803d); + } + .success-banner sl-icon { + font-size: 2rem; + display: block; + margin: 0 auto 0.5rem; + } + + input[type='file'] { + display: none; + } `; + /* ================================================================ */ + /* Lifecycle */ + /* ================================================================ */ + override connectedCallback(): void { super.connectedCallback(); void this.checkCapabilities(); } + override disconnectedCallback(): void { + super.disconnectedCallback(); + if (this.debounceTimer) clearTimeout(this.debounceTimer); + if (this.redirectTimer) clearTimeout(this.redirectTimer); + } + private async checkCapabilities(): Promise { this.loading = true; try { @@ -183,6 +470,10 @@ export class ScionPageSkillCreate extends LitElement { } } + /* ================================================================ */ + /* Helpers */ + /* ================================================================ */ + private get parsedTags(): string[] { if (!this.tagsInput.trim()) return []; return this.tagsInput @@ -191,71 +482,394 @@ export class ScionPageSkillCreate extends LitElement { .filter((t) => t.length > 0); } - private async handleSubmit(): Promise { - if (!this.name.trim()) { - this.error = 'Skill name is required.'; + private get hasSkillMd(): boolean { + return this.skillMdContent.trim().length > 0; + } + + private get allFiles(): SelectedFile[] { + if ( + this.cachedAllFiles !== null && + this.cachedSkillMdContent === this.skillMdContent && + this.cachedAdditionalFiles === this.additionalFiles + ) { + return this.cachedAllFiles; + } + + const files: SelectedFile[] = []; + if (this.hasSkillMd) { + const blob = new Blob([this.skillMdContent], { type: 'text/markdown' }); + const skillMdFile = new File([blob], 'SKILL.md', { type: 'text/markdown' }); + files.push({ file: skillMdFile, path: 'SKILL.md' }); + } + for (const f of this.additionalFiles) { + if (f.path === 'SKILL.md' && this.hasSkillMd) continue; + files.push(f); + } + + this.cachedAllFiles = files; + this.cachedSkillMdContent = this.skillMdContent; + this.cachedAdditionalFiles = this.additionalFiles; + return files; + } + + private get hasFiles(): boolean { + return this.allFiles.length > 0; + } + + private formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + private validateSemver(v: string): boolean { + return /^\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/.test(v.replace(/^v/, '')); + } + + private cleanVersion(v: string): string { + return v.trim().replace(/^v/, ''); + } + + /* ================================================================ */ + /* SKILL.md content handling */ + /* ================================================================ */ + + private onSkillMdInput(e: Event): void { + const target = e.target as HTMLElement & { value: string }; + const value = target.value; + + if (new Blob([value]).size > MAX_PASTE_SIZE) { + this.validationError = 'SKILL.md content exceeds 512 KB. Please upload the file instead.'; + this.skillMdContent = ''; + target.value = ''; return; } + this.validationError = null; + this.skillMdContent = value; + this.debounceParseFrontmatter(); + } + + private debounceParseFrontmatter(): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => this.applyFrontmatter(), 300); + } - if (this.scope === 'project' && !this.scopeId.trim()) { - this.error = 'Project ID is required for project scope.'; + private applyFrontmatter(): void { + if (!this.hasSkillMd) { + if (!this.editedFields.has('name')) this.name = ''; + if (!this.editedFields.has('description')) this.description = ''; + if (!this.editedFields.has('tags')) this.tagsInput = ''; return; } - this.submitting = true; - this.error = null; + const fm = parseSkillFrontmatter(this.skillMdContent); + if (!fm) return; - try { - const body: Record = { - name: this.name.trim(), - scope: this.scope, - visibility: this.visibility, - }; - - if (this.description.trim()) { - body.description = this.description.trim(); - } + if (fm.name !== undefined && !this.editedFields.has('name')) { + this.name = fm.name; + } + if (fm.description !== undefined && !this.editedFields.has('description')) { + this.description = fm.description; + } + if (fm.tags !== undefined && !this.editedFields.has('tags')) { + this.tagsInput = fm.tags.join(', '); + } + } - if (this.scope === 'project' && this.scopeId.trim()) { - body.scopeId = this.scopeId.trim(); - } + private onUploadSkillMdClick(): void { + if (!this.skillMdInputRef) { + this.skillMdInputRef = document.createElement('input'); + this.skillMdInputRef.type = 'file'; + this.skillMdInputRef.accept = '.md'; + this.skillMdInputRef.addEventListener('change', () => this.onSkillMdFileSelected()); + } + this.skillMdInputRef.click(); + } - const tags = this.parsedTags; - if (tags.length > 0) { - body.tags = tags; - } + private onSkillMdFileSelected(): void { + const file = this.skillMdInputRef?.files?.[0]; + if (!file) return; - const response = await apiFetch('/api/v1/skills', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + if (file.size > MAX_PASTE_SIZE) { + this.validationError = 'SKILL.md file exceeds 512 KB limit.'; + this.skillMdInputRef!.value = ''; + return; + } - if (!response.ok) { - throw new Error(await extractApiError(response, `HTTP ${response.status}`)); - } + const reader = new FileReader(); + reader.onload = () => { + this.skillMdContent = reader.result as string; + this.validationError = null; + this.applyFrontmatter(); + }; + reader.readAsText(file); + this.skillMdInputRef!.value = ''; + } + + private isAutoPopulated(field: string): boolean { + return this.hasSkillMd && !this.editedFields.has(field); + } + + private onFieldInput(field: string, e: Event): void { + const value = (e.target as HTMLElement & { value: string }).value; + this.editedFields.add(field); + switch (field) { + case 'name': + this.name = value; + break; + case 'description': + this.description = value; + break; + case 'tags': + this.tagsInput = value; + break; + } + } + + private resetFromFrontmatter(): void { + this.editedFields.clear(); + this.applyFrontmatter(); + } + + /* ================================================================ */ + /* Additional file handling */ + /* ================================================================ */ - const result = (await response.json()) as { skill?: { id: string }; id?: string }; - const skillId = result.skill?.id || result.id; + private onDropZoneClick(): void { + if (!this.fileInputRef) { + this.fileInputRef = document.createElement('input'); + this.fileInputRef.type = 'file'; + this.fileInputRef.multiple = true; + this.fileInputRef.addEventListener('change', () => this.onFilesSelected()); + } + this.fileInputRef.click(); + } + + private onFilesSelected(): void { + if (!this.fileInputRef?.files) return; + this.addFiles(Array.from(this.fileInputRef.files)); + this.fileInputRef.value = ''; + } + + private onDrop(e: DragEvent): void { + e.preventDefault(); + (e.currentTarget as HTMLElement).classList.remove('dragover'); + if (!e.dataTransfer?.files) return; + this.addFiles(Array.from(e.dataTransfer.files)); + } + + private onDragOver(e: DragEvent): void { + e.preventDefault(); + (e.currentTarget as HTMLElement).classList.add('dragover'); + } + + private onDragLeave(e: DragEvent): void { + (e.currentTarget as HTMLElement).classList.remove('dragover'); + } + + private addFiles(files: File[]): void { + const newFiles: SelectedFile[] = []; + let hasDuplicateSkillMd = false; - if (!skillId) { - throw new Error('No skill ID in response'); + for (const file of files) { + const path = file.webkitRelativePath || file.name; + if (path === 'SKILL.md' && this.hasSkillMd) { + hasDuplicateSkillMd = true; + continue; } + if (!this.additionalFiles.some((f) => f.path === path)) { + newFiles.push({ file, path }); + } + } + + this.duplicateSkillMdWarning = hasDuplicateSkillMd; + this.additionalFiles = [...this.additionalFiles, ...newFiles]; + this.validationError = null; + } + + private removeFile(index: number): void { + this.additionalFiles = this.additionalFiles.filter((_, i) => i !== index); + this.duplicateSkillMdWarning = false; + } + + /* ================================================================ */ + /* Validation */ + /* ================================================================ */ + + private validateForPublish(): string | null { + if (this.validationError) return this.validationError; + if (!this.name.trim()) return 'Skill name is required.'; + if (this.scope === 'project' && !this.scopeId.trim()) + return 'Project ID is required for project scope.'; + if (!this.cleanVersion(this.version)) return 'Version is required.'; + if (!this.validateSemver(this.cleanVersion(this.version))) + return 'Version must be valid semver (e.g. 1.0.0).'; + + const files = this.allFiles; + if (!files.some((f) => f.path === 'SKILL.md')) + return 'SKILL.md is required when publishing. Paste content above or upload the file.'; + if (files.length > MAX_FILES) return `Maximum ${MAX_FILES} files allowed.`; + const oversize = files.find((f) => f.file.size > MAX_FILE_SIZE); + if (oversize) return `File "${oversize.path}" exceeds 10 MB limit.`; + const totalSize = files.reduce((sum, f) => sum + f.file.size, 0); + if (totalSize > MAX_TOTAL_SIZE) return 'Total file size exceeds 50 MB limit.'; + return null; + } + private validateForCreate(): string | null { + if (this.validationError) return this.validationError; + if (!this.name.trim()) return 'Skill name is required.'; + if (this.scope === 'project' && !this.scopeId.trim()) + return 'Project ID is required for project scope.'; + return null; + } + + /* ================================================================ */ + /* Submit — Create Only */ + /* ================================================================ */ + + private async handleCreateOnly(): Promise { + const err = this.validateForCreate(); + if (err) { + this.validationError = err; + return; + } + + this.flowState = 'creating'; + this.error = null; + this.validationError = null; + + try { + const skillId = await this.createSkill(); + this.createdSkillId = skillId; window.history.pushState({}, '', `/skills/${skillId}`); window.dispatchEvent(new PopStateEvent('popstate')); } catch (err) { - console.error('Failed to create skill:', err); + this.flowState = 'form'; this.error = err instanceof Error ? err.message : 'Failed to create skill'; - } finally { - this.submitting = false; } } + /* ================================================================ */ + /* Submit — Create & Publish */ + /* ================================================================ */ + + private async handleCreateAndPublish(): Promise { + const err = this.validateForPublish(); + if (err) { + this.validationError = err; + return; + } + + this.validationError = null; + this.error = null; + this.flowState = 'creating'; + + try { + // Step 1: Create skill + const skillId = await this.createSkill(); + this.createdSkillId = skillId; + + // Step 2: Publish version via multipart POST + this.flowState = 'publishing'; + await this.publishVersion(skillId); + + // Step 3: Done — redirect + this.flowState = 'done'; + this.redirectTimer = setTimeout(() => { + window.history.pushState({}, '', `/skills/${skillId}`); + window.dispatchEvent(new PopStateEvent('popstate')); + }, 1500); + } catch (err) { + console.error('Create & publish failed:', err); + if (this.flowState === 'creating') { + this.flowState = 'form'; + this.error = err instanceof Error ? err.message : 'Failed to create skill'; + } else { + this.flowState = 'error'; + this.error = err instanceof Error ? err.message : 'Publishing failed'; + } + } + } + + /* ================================================================ */ + /* API helpers */ + /* ================================================================ */ + + private async createSkill(): Promise { + const body: Record = { + name: this.name.trim(), + scope: this.scope, + visibility: this.visibility, + }; + if (this.description.trim()) body.description = this.description.trim(); + if (this.scope === 'project' && this.scopeId.trim()) body.scopeId = this.scopeId.trim(); + const tags = this.parsedTags; + if (tags.length > 0) body.tags = tags; + + const res = await apiFetch('/api/v1/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(await extractApiError(res, `HTTP ${res.status}`)); + } + const result = (await res.json()) as { skill?: { id: string }; id?: string }; + const skillId = result.skill?.id || result.id; + if (!skillId) throw new Error('No skill ID in response'); + return skillId; + } + + private async publishVersion(skillId: string): Promise { + const files = this.allFiles; + + const formData = new FormData(); + formData.append('version', this.cleanVersion(this.version)); + for (const sf of files) { + formData.append('file', sf.file, sf.path); + } + + const res = await apiFetch(`/api/v1/skills/${skillId}/versions`, { + method: 'POST', + body: formData, + // Do NOT set Content-Type — the browser auto-sets it with the multipart boundary + }); + + if (!res.ok) { + throw new Error(await extractApiError(res, `HTTP ${res.status}`)); + } + } + + private async retryPublish(): Promise { + if (!this.createdSkillId) return; + this.error = null; + this.flowState = 'publishing'; + + try { + await this.publishVersion(this.createdSkillId); + + this.flowState = 'done'; + this.redirectTimer = setTimeout(() => { + window.history.pushState({}, '', `/skills/${this.createdSkillId}`); + window.dispatchEvent(new PopStateEvent('popstate')); + }, 1500); + } catch (err) { + this.flowState = 'error'; + this.error = err instanceof Error ? err.message : 'Retry failed'; + } + } + + /* ================================================================ */ + /* Render */ + /* ================================================================ */ + override render() { if (this.loading) { return html` -
+

Loading...

@@ -268,10 +882,21 @@ export class ScionPageSkillCreate extends LitElement { Back to Skills -
- -

Access Denied

-

You do not have permission to create skills.

+ + ${this.flowState === 'form' || this.flowState === 'creating' + ? this.renderForm() + : this.renderProgress()} + `; + } + + /* ---------------------------------------------------------------- */ + /* Form view */ + /* ---------------------------------------------------------------- */ + + private renderForm() { + const isSubmitting = this.flowState === 'creating'; + + return html`
${this.error ? html` @@ -305,115 +944,416 @@ export class ScionPageSkillCreate extends LitElement {
` : nothing} + ${this.validationError + ? html` +
+ + ${this.validationError} +
+ ` + : nothing} + ${this.duplicateSkillMdWarning + ? html` +
+ + A SKILL.md was uploaded via the drop zone but content already exists above. The + textarea content will be used. +
+ ` + : nothing} -
-
- - { this.name = (e.target as HTMLElement & { value: string }).value; }} - required - > -
- -
- - { this.description = (e.target as HTMLElement & { value: string }).value; }} - maxlength="500" - rows="3" - > -
+ +

SKILL.md Content

+

+ Paste your SKILL.md below or upload the file. Metadata fields will auto-populate from + frontmatter. +

-
- - { this.scope = (e.target as HTMLElement & { value: string }).value as 'global' | 'project' | 'user'; }} +
+ this.onSkillMdInput(e)} + ?disabled=${isSubmitting} + > +
+ this.onUploadSkillMdClick()} + ?disabled=${isSubmitting} > - Global - Project - User - -
- ${this.scope === 'global' - ? 'Available to all projects and agents.' - : this.scope === 'project' - ? 'Scoped to a specific project.' - : 'Scoped to your user account.'} -
+ + Upload SKILL.md +
+ ${this.editedFields.size > 0 && this.hasSkillMd + ? html` + this.resetFromFrontmatter()} + > + + Reset from SKILL.md + + ` + : nothing}
+
- ${this.scope === 'project' ? html` -
- - { this.scopeId = (e.target as HTMLElement & { value: string }).value; }} - > -
- ` : nothing} - ${this.scope === 'user' ? html` -
-
Skills will be created under your user account.
-
- ` : nothing} - -
- - { this.visibility = (e.target as HTMLElement & { value: string }).value as 'private' | 'public'; }} - > - Private - Public - +
+ + +

Skill Details

+ +
+ + this.onFieldInput('name', e)} + ?disabled=${isSubmitting} + required + > +
+ +
+ + this.onFieldInput('description', e)} + ?disabled=${isSubmitting} + maxlength="500" + rows="3" + > +
+ +
+ + { + this.scope = (e.target as HTMLElement & { value: string }).value as + | 'global' + | 'project' + | 'user'; + }} + ?disabled=${isSubmitting} + > + Global + Project + User + +
+ ${this.scope === 'global' + ? 'Available to all projects and agents.' + : this.scope === 'project' + ? 'Scoped to a specific project.' + : 'Scoped to your user account.'}
+
-
- - { this.tagsInput = (e.target as HTMLElement & { value: string }).value; }} - > -
Comma-separated list of tags.
- ${this.parsedTags.length > 0 ? html` -
- ${this.parsedTags.map((tag) => html`${tag}`)} + ${this.scope === 'project' + ? html` +
+ + { + this.scopeId = (e.target as HTMLElement & { value: string }).value; + }} + ?disabled=${isSubmitting} + >
- ` : nothing} -
+ ` + : nothing} + ${this.scope === 'user' + ? html` +
+
Skills will be created under your user account.
+
+ ` + : nothing} -
+
+ + { + this.visibility = (e.target as HTMLElement & { value: string }).value as + | 'private' + | 'public'; + }} + > + Private + Public + +
+ +
+ + this.onFieldInput('tags', e)} + ?disabled=${isSubmitting} + > +
Comma-separated list of tags.
+ ${this.parsedTags.length > 0 + ? html` +
+ ${this.parsedTags.map((tag) => html`${tag}`)} +
+ ` + : nothing} +
+ +
+ + +

+ ${this.hasSkillMd ? 'Additional Files (optional)' : 'Files & First Version (optional)'} +

+ +
this.onDropZoneClick()} + @drop=${(e: DragEvent) => this.onDrop(e)} + @dragover=${(e: DragEvent) => this.onDragOver(e)} + @dragleave=${(e: DragEvent) => this.onDragLeave(e)} + > + + ${this.hasSkillMd + ? 'Drop additional files here or click to browse' + : 'Drop files here or click to browse'} +
+ + ${this.allFiles.length > 0 + ? html` +
    + ${this.allFiles.map( + (sf) => html` +
  • +
    + + ${sf.path} + + ${this.formatFileSize(sf.file.size)} + ${sf.path === 'SKILL.md' && this.hasSkillMd ? '-- from content above' : ''} + +
    + ${sf.path === 'SKILL.md' && this.hasSkillMd + ? nothing + : html` + + `} +
  • + ` + )} +
+ ` + : nothing} + ${this.hasFiles + ? html` +
+ + { + this.version = (e.target as HTMLElement & { value: string }).value; + }} + ?disabled=${isSubmitting} + style="max-width: 200px;" + > +
+ ` + : nothing} + + +
+ ${this.hasFiles + ? html` + this.handleCreateAndPublish()} + > + + Create & Publish v${this.version || '1.0.0'} + + this.handleCreateOnly()} + > + Create Only + + ` + : html` + this.handleCreateOnly()} + > + + Create Skill + + `} + + Cancel +
`; } + + /* ---------------------------------------------------------------- */ + /* Progress view */ + /* ---------------------------------------------------------------- */ + + private renderProgress() { + const stepDone = (s: FlowState) => { + const order: FlowState[] = ['creating', 'publishing', 'done']; + const current = order.indexOf(this.flowState); + const target = order.indexOf(s); + return target < current; + }; + + const stepActive = (s: FlowState) => this.flowState === s; + const stepError = (s: FlowState) => + this.flowState === 'error' && s === 'publishing'; + + const stepClass = (s: FlowState) => { + if (stepDone(s)) return 'progress-step done'; + if (stepError(s)) return 'progress-step error'; + if (stepActive(s)) return 'progress-step active'; + return 'progress-step'; + }; + + const stepIcon = (s: FlowState) => { + if (stepDone(s)) return html``; + if (stepError(s)) return html``; + if (stepActive(s)) return html``; + return html``; + }; + + return html` +
+

+ ${this.flowState === 'done' + ? `Created & published ${this.name} v${this.version}` + : `Creating & Publishing ${this.name} v${this.version}`} +

+ + ${this.flowState === 'done' + ? html` +
+ +

+ Skill "${this.name}" created and version ${this.version} published + successfully! +

+

+ Redirecting to skill detail page... +

+
+ ` + : html` +
+
+ ${stepIcon('creating')} + Creating skill... + ${stepDone('creating') ? 'done' : ''} +
+
+ ${stepIcon('publishing')} + Uploading & publishing... + ${stepDone('publishing') ? 'done' : ''} +
+
+ + ${this.flowState === 'publishing' + ? html` + + ` + : nothing} + ${this.flowState === 'error' + ? html` +
+ + ${this.error} +
+ ` + : nothing} + `} + ${this.flowState === 'error' + ? html` +
+ this.retryPublish()}> + + Retry Publishing + + ${this.createdSkillId + ? html` + + + Go to Skill + + + + ` + : nothing} +
+ ` + : nothing} +
+ `; + } } declare global { diff --git a/web/src/components/shared/agent-log-viewer.ts b/web/src/components/shared/agent-log-viewer.ts index a0631e6e9..021d4d80d 100644 --- a/web/src/components/shared/agent-log-viewer.ts +++ b/web/src/components/shared/agent-log-viewer.ts @@ -54,6 +54,10 @@ export class ScionAgentLogViewer extends LitElement { @property() agentId = ''; + /** Whether Cloud Logging is available for this agent. */ + @property({ type: Boolean }) + cloudLogging = true; + /** Available brokers for filtering (id -> name). */ @property({ type: Object }) brokers: Record = {}; @@ -68,6 +72,9 @@ export class ScionAgentLogViewer extends LitElement { @state() private selectedBrokerId = ''; @state() private selectedSeverity = ''; + /** Plain-text logs from the broker (fallback when cloud logging is unavailable). */ + @state() private brokerLogs: string | null = null; + private eventSource: EventSource | null = null; static override styles = css` @@ -184,6 +191,20 @@ export class ScionAgentLogViewer extends LitElement { background: var(--scion-surface, #ffffff); } + .broker-logs { + background: var(--scion-bg-subtle, #f1f5f9); + border: 1px solid var(--scion-border, #e2e8f0); + border-radius: var(--sl-border-radius-medium, 0.5rem); + padding: 1rem; + font-family: var(--scion-font-mono, monospace); + font-size: 0.8125rem; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + max-height: 600px; + overflow-y: auto; + } + .stream-indicator { display: inline-flex; align-items: center; @@ -217,7 +238,11 @@ export class ScionAgentLogViewer extends LitElement { loadLogs(): void { if (this.loaded) return; this.loaded = true; - void this.fetchLogs(); + if (this.cloudLogging) { + void this.fetchLogs(); + } else { + void this.fetchBrokerLogs(); + } } private async fetchLogs(): Promise { @@ -256,6 +281,34 @@ export class ScionAgentLogViewer extends LitElement { } } + private async fetchBrokerLogs(): Promise { + if (!this.agentId) return; + this.loading = true; + this.error = null; + + try { + const res = await apiFetch(`/api/v1/agents/${this.agentId}/logs?tail=500`); + if (!res.ok) { + const errData = await res.json().catch(() => ({})) as { error?: { message?: string }; message?: string }; + throw new Error( + (errData.error as { message?: string })?.message || errData.message || `HTTP ${res.status}` + ); + } + + const contentType = res.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + const data = await res.json() as { logs?: string }; + this.brokerLogs = data.logs || ''; + } else { + this.brokerLogs = await res.text(); + } + } catch (err) { + this.error = err instanceof Error ? err.message : 'Failed to fetch logs'; + } finally { + this.loading = false; + } + } + private mergeEntries(newEntries: CloudLogEntry[]): void { for (const entry of newEntries) { if (!this.entryMap.has(entry.insertId)) { @@ -371,7 +424,11 @@ export class ScionAgentLogViewer extends LitElement { } private handleRefresh(): void { - void this.fetchLogs(); + if (this.cloudLogging) { + void this.fetchLogs(); + } else { + void this.fetchBrokerLogs(); + } } // ------------------------------------------------------------------------- @@ -379,12 +436,67 @@ export class ScionAgentLogViewer extends LitElement { // ------------------------------------------------------------------------- override render() { + if (!this.cloudLogging) { + return html` + ${this.renderBrokerToolbar()} + ${this.renderBrokerContent()} + `; + } return html` ${this.renderToolbar()} ${this.renderContent()} `; } + private renderBrokerToolbar() { + return html` +
+ + + Refresh + +
+ `; + } + + private renderBrokerContent() { + if (this.loading && this.brokerLogs === null) { + return html` +
+ + Loading logs... +
+ `; + } + + if (this.error && this.brokerLogs === null) { + return html` +
+ + ${this.error} + Retry +
+ `; + } + + if (!this.brokerLogs) { + return html` +
+ + No log entries found +
+ `; + } + + return html`
${this.brokerLogs}
`; + } + private renderToolbar() { const brokerEntries = Object.entries(this.brokers); return html` diff --git a/web/src/components/shared/nav.ts b/web/src/components/shared/nav.ts index bfd62200d..1e8a5f909 100644 --- a/web/src/components/shared/nav.ts +++ b/web/src/components/shared/nav.ts @@ -51,6 +51,7 @@ const NAV_SECTIONS: NavSection[] = [ { path: '/agents', label: 'Agents', icon: 'cpu' }, { path: '/brokers', label: 'Brokers', icon: 'hdd-rack' }, { path: '/skills', label: 'Skills', icon: 'lightning-charge' }, + { path: '/metrics', label: 'Metrics', icon: 'graph-up' }, ], }, ]; diff --git a/web/src/components/shared/resource-import.ts b/web/src/components/shared/resource-import.ts index d7ddc1fd6..ad9b20139 100644 --- a/web/src/components/shared/resource-import.ts +++ b/web/src/components/shared/resource-import.ts @@ -277,7 +277,7 @@ export class ScionResourceImport extends LitElement { } if (discovered.count === 1) { - await this.executeImport(); + await this.executeImport(discovered.resources); return; } diff --git a/web/src/components/shared/skill-publish-dialog.ts b/web/src/components/shared/skill-publish-dialog.ts index 360763f40..3dc90f96c 100644 --- a/web/src/components/shared/skill-publish-dialog.ts +++ b/web/src/components/shared/skill-publish-dialog.ts @@ -17,31 +17,23 @@ /** * Multi-step publish version dialog for skills. * - * Flow: file select → upload (parallel, concurrency-limited) → finalize. - * Uses the backend's 2-phase signed-URL upload pattern. + * Flow: file select → upload → done. + * Uses a single multipart POST for server-side upload. */ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import type { SkillVersion, SkillUploadUrl } from '../../shared/types.js'; +import type { SkillVersion } from '../../shared/types.js'; import { apiFetch, extractApiError } from '../../client/api.js'; -type DialogStep = 'input' | 'uploading' | 'finalizing' | 'done' | 'error'; +type DialogStep = 'input' | 'uploading' | 'done' | 'error'; interface SelectedFile { file: File; path: string; } -interface UploadResult { - path: string; - size: number; - hash: string; - status: 'pending' | 'uploading' | 'done' | 'failed'; - error?: string; -} - @customElement('scion-skill-publish-dialog') export class ScionSkillPublishDialog extends LitElement { @property({ type: String }) skillId = ''; @@ -52,8 +44,6 @@ export class ScionSkillPublishDialog extends LitElement { @state() private version = ''; @state() private selectedFiles: SelectedFile[] = []; @state() private validationError: string | null = null; - @state() private uploadResults: UploadResult[] = []; - @state() private uploadedCount = 0; @state() private error: string | null = null; @state() private createdVersion: SkillVersion | null = null; @@ -130,15 +120,6 @@ export class ScionSkillPublishDialog extends LitElement { font-size: 0.75rem; flex-shrink: 0; } - .file-status { - display: flex; - align-items: center; - gap: 0.25rem; - flex-shrink: 0; - } - .file-status.done { color: var(--sl-color-success-600, #16a34a); } - .file-status.failed { color: var(--sl-color-danger-600, #dc2626); } - .file-status.uploading { color: var(--scion-primary, #3b82f6); } .remove-btn { cursor: pointer; @@ -212,8 +193,6 @@ export class ScionSkillPublishDialog extends LitElement { this.version = ''; this.selectedFiles = []; this.validationError = null; - this.uploadResults = []; - this.uploadedCount = 0; this.error = null; this.createdVersion = null; } @@ -319,72 +298,26 @@ export class ScionSkillPublishDialog extends LitElement { this.step = 'uploading'; this.error = null; - this.uploadedCount = 0; - this.uploadResults = this.selectedFiles.map((f) => ({ - path: f.path, - size: f.file.size, - hash: '', - status: 'pending' as const, - })); - - try { - // Step 1: Create draft version and get upload URLs - const filesPayload = this.selectedFiles.map((f) => ({ path: f.path, size: f.file.size })); - const createRes = await apiFetch(`/api/v1/skills/${this.skillId}/versions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: this.version.trim(), files: filesPayload }), - }); - - if (!createRes.ok) { - const msg = await extractApiError(createRes, `HTTP ${createRes.status}`); - throw new Error(msg); - } - - const createData = (await createRes.json()) as { - version?: SkillVersion; - uploadUrls?: SkillUploadUrl[]; - urls?: SkillUploadUrl[]; - }; - - const uploadUrls = createData.uploadUrls || createData.urls || []; - - // Step 2: Upload files with concurrency limit - await this.uploadFiles(uploadUrls); - // Check for failures - const failed = this.uploadResults.filter((r) => r.status === 'failed'); - if (failed.length > 0) { - this.step = 'error'; - this.error = `${failed.length} file(s) failed to upload. You can retry.`; - return; - } + const formData = new FormData(); + formData.append('version', this.version.trim()); + for (const sf of this.selectedFiles) { + formData.append('file', sf.file, sf.path); + } - // Step 3: Finalize - this.step = 'finalizing'; - const manifest = { - files: this.uploadResults.map((r) => ({ - path: r.path, - size: r.size, - hash: r.hash, - })), - }; - - const finalizeRes = await apiFetch(`/api/v1/skills/${this.skillId}/finalize`, { + try { + const res = await apiFetch(`/api/v1/skills/${this.skillId}/versions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: this.version.trim(), manifest }), + body: formData, + // DO NOT set Content-Type — browser sets it with the multipart boundary }); - if (!finalizeRes.ok) { - const msg = await extractApiError(finalizeRes, `HTTP ${finalizeRes.status}`); - throw new Error(msg); + if (!res.ok) { + throw new Error(await extractApiError(res, `HTTP ${res.status}`)); } - const finalData = (await finalizeRes.json()) as { version?: SkillVersion } | SkillVersion; - this.createdVersion = ('version' in finalData && finalData.version) - ? finalData.version as SkillVersion - : finalData as SkillVersion; + const data = (await res.json()) as { version?: SkillVersion }; + this.createdVersion = data.version ?? null; this.step = 'done'; this.dispatchEvent(new CustomEvent('skill-version-published', { @@ -399,152 +332,6 @@ export class ScionSkillPublishDialog extends LitElement { } } - private async uploadFiles(uploadUrls: SkillUploadUrl[], indices?: number[]): Promise { - if (!window.crypto || !window.crypto.subtle) { - throw new Error('Cryptography APIs (crypto.subtle) are not available. This feature requires a secure context (HTTPS or localhost).'); - } - - const concurrency = 4; - const queue = indices ?? Array.from({ length: this.selectedFiles.length }, (_, i) => i); - let queuePos = 0; - - const uploadOne = async (): Promise => { - while (queuePos < queue.length) { - const i = queue[queuePos++]; - const sf = this.selectedFiles[i]; - const urlInfo = uploadUrls.find((u) => u.path === sf.path); - if (!urlInfo) { - this.uploadResults = this.uploadResults.map((r, ri) => - ri === i ? { ...r, status: 'failed' as const, error: 'No upload URL' } : r - ); - continue; - } - - this.uploadResults = this.uploadResults.map((r, ri) => - ri === i ? { ...r, status: 'uploading' as const } : r - ); - this.requestUpdate(); - - try { - // Compute SHA-256 hash - const buffer = await sf.file.arrayBuffer(); - const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - const hash = `sha256:${hashHex}`; - - // Upload - const headers: Record = urlInfo.headers || {}; - let res = await fetch(urlInfo.url, { - method: urlInfo.method || 'PUT', - headers, - body: buffer, - }); - - // Retry once on failure - if (!res.ok) { - res = await fetch(urlInfo.url, { - method: urlInfo.method || 'PUT', - headers, - body: buffer, - }); - } - - if (!res.ok) { - throw new Error(`Upload failed: ${res.status}`); - } - - this.uploadResults = this.uploadResults.map((r, ri) => - ri === i ? { ...r, status: 'done' as const, hash } : r - ); - this.uploadedCount++; - } catch (err) { - this.uploadResults = this.uploadResults.map((r, ri) => - ri === i ? { ...r, status: 'failed' as const, error: err instanceof Error ? err.message : 'Upload failed' } : r - ); - } - this.requestUpdate(); - } - }; - - const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => uploadOne()); - await Promise.all(workers); - } - - private async retryFailed(): Promise { - this.error = null; - this.step = 'uploading'; - - const failedFiles = this.uploadResults - .map((r, i) => ({ result: r, index: i, file: this.selectedFiles[i] })) - .filter((x) => x.result.status === 'failed'); - - try { - // Use the upload endpoint to get fresh signed URLs for the existing draft version - const filesPayload = failedFiles.map((x) => ({ path: x.file.path, size: x.file.file.size })); - const createRes = await apiFetch(`/api/v1/skills/${this.skillId}/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: this.version.trim(), files: filesPayload }), - }); - - if (!createRes.ok) { - throw new Error(await extractApiError(createRes, 'Failed to get upload URLs')); - } - - const data = (await createRes.json()) as { uploadUrls?: SkillUploadUrl[]; urls?: SkillUploadUrl[] }; - const uploadUrls = data.uploadUrls || data.urls || []; - - // Reset failed to pending - for (const f of failedFiles) { - this.uploadResults = this.uploadResults.map((r, ri) => { - if (ri !== f.index) return r; - const { error: _, ...rest } = r; - return { ...rest, status: 'pending' as const }; - }); - } - - const failedIndices = failedFiles.map((f) => f.index); - await this.uploadFiles(uploadUrls, failedIndices); - - const stillFailed = this.uploadResults.filter((r) => r.status === 'failed'); - if (stillFailed.length > 0) { - this.step = 'error'; - this.error = `${stillFailed.length} file(s) still failed.`; - } else { - // All done — proceed to finalize - this.step = 'finalizing'; - const manifest = { - files: this.uploadResults.map((r) => ({ path: r.path, size: r.size, hash: r.hash })), - }; - const finalizeRes = await apiFetch(`/api/v1/skills/${this.skillId}/finalize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: this.version.trim(), manifest }), - }); - - if (!finalizeRes.ok) { - throw new Error(await extractApiError(finalizeRes, 'Finalize failed')); - } - - const finalData = (await finalizeRes.json()) as { version?: SkillVersion } | SkillVersion; - this.createdVersion = ('version' in finalData && finalData.version) - ? finalData.version as SkillVersion - : finalData as SkillVersion; - this.step = 'done'; - - this.dispatchEvent(new CustomEvent('skill-version-published', { - detail: { version: this.createdVersion }, - bubbles: true, - composed: true, - })); - } - } catch (err) { - this.step = 'error'; - this.error = err instanceof Error ? err.message : 'Retry failed'; - } - } - // -- Dialog events -- private onDialogHide(): void { @@ -564,7 +351,6 @@ export class ScionSkillPublishDialog extends LitElement { > ${this.step === 'input' ? this.renderInput() : nothing} ${this.step === 'uploading' ? this.renderUploading() : nothing} - ${this.step === 'finalizing' ? this.renderFinalizing() : nothing} ${this.step === 'done' ? this.renderDone() : nothing} ${this.step === 'error' ? this.renderError() : nothing} @@ -638,46 +424,14 @@ export class ScionSkillPublishDialog extends LitElement { } private renderUploading() { - const total = this.uploadResults.length; - const done = this.uploadResults.filter((r) => r.status === 'done').length; - const pct = total > 0 ? Math.round((done / total) * 100) : 0; - return html`
- Uploading files... - ${done} / ${total} + Uploading & publishing...
- +
- -
    - ${this.uploadResults.map((r) => html` -
  • -
    - - ${r.path} - ${this.formatFileSize(r.size)} -
    -
    - ${r.status === 'done' ? html`` : nothing} - ${r.status === 'uploading' ? html`` : nothing} - ${r.status === 'failed' ? html`` : nothing} - ${r.status === 'pending' ? html`` : nothing} -
    -
  • - `)} -
-
- `; - } - - private renderFinalizing() { - return html` -
- -

Finalizing version ${this.version}...

`; } @@ -703,30 +457,13 @@ export class ScionSkillPublishDialog extends LitElement { ${this.error}
- - ${this.uploadResults.some((r) => r.status === 'failed') ? html` -
    - ${this.uploadResults.filter((r) => r.status === 'failed').map((r) => html` -
  • -
    - - ${r.path} -
    -
    - - ${r.error || 'Failed'} -
    -
  • - `)} -
- ` : nothing}
{ this.open = false; }}> Cancel - this.retryFailed()}> + this.startPublish()}> - Retry Failed + Retry `; } diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index be301497e..5b7522443 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -373,6 +373,7 @@ export interface AgentAppliedConfig { image?: string; harnessConfig?: string; harnessAuth?: string; + noAuth?: boolean; model?: string; profile?: string; task?: string; @@ -460,6 +461,15 @@ export interface TemplateFileInfo { mode?: string; } +export interface HarnessConfigData { + harness?: string; + image?: string; + user?: string; + model?: string; + args?: string[]; + env?: Record; +} + export interface HarnessConfig { id: string; name: string; @@ -467,10 +477,12 @@ export interface HarnessConfig { displayName?: string; description?: string; harness: string; + config?: HarnessConfigData; status: string; scope: string; scopeId?: string; contentHash?: string; + sourceUrl?: string; files?: TemplateFileInfo[]; created?: string; updated?: string;