feat(ext): add extension doctor diagnostics
- commit
- 99d5537a9ae4d35a3bcceb1976cd888c984386de
- author
- Jia Wei · · 2026-07-02 09:07 UTC
- parent
- 5c27086
- signature
- unsigned
7 files · +507 -6
M README.md +7 -0
| @@ -676,12 +676,19 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens | |||
| 676 | 676 | ```bash | |
| 677 | 677 | zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/ | |
| 678 | 678 | zot ext list # show installed extensions | |
| 679 | + | zot ext doctor # diagnose load, registration, and conflict issues | |
| 679 | 680 | zot ext logs <name> [-f] # cat or tail the extension's stderr log | |
| 680 | 681 | zot ext enable <name> # re-enable a disabled extension | |
| 681 | 682 | zot ext disable <name> # disable without removing | |
| 682 | 683 | zot ext remove <name> # delete an extension directory | |
| 683 | 684 | ``` | |
| 684 | 685 | ||
| 686 | + | `zot ext doctor` keeps normal extension startup fail-soft, but gives | |
| 687 | + | you an explicit troubleshooting view: manifest errors, disabled or | |
| 688 | + | shadowed extensions, subprocess load errors, ready/auto-ready status, | |
| 689 | + | registered commands/tools, registration conflicts, warnings, and the | |
| 690 | + | stderr log path. | |
| 691 | + | ||
| 685 | 692 | For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name. | |
| 686 | 693 | ||
| 687 | 694 | ### Updating extensions | |
| @@ -676,12 +676,19 @@ zot can be extended in any language via a subprocess + JSON-RPC protocol. Extens | |||||
| 676 | ```bash | 676 | ```bash | ||
| 677 | zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/ | 677 | zot ext install <path|git-url> # copy / clone into $ZOT_HOME/extensions/ | ||
| 678 | zot ext list # show installed extensions | 678 | zot ext list # show installed extensions | ||
| 679 | + | zot ext doctor # diagnose load, registration, and conflict issues | |||
| 679 | zot ext logs <name> [-f] # cat or tail the extension's stderr log | 680 | zot ext logs <name> [-f] # cat or tail the extension's stderr log | ||
| 680 | zot ext enable <name> # re-enable a disabled extension | 681 | zot ext enable <name> # re-enable a disabled extension | ||
| 681 | zot ext disable <name> # disable without removing | 682 | zot ext disable <name> # disable without removing | ||
| 682 | zot ext remove <name> # delete an extension directory | 683 | zot ext remove <name> # delete an extension directory | ||
| 683 | ``` | 684 | ``` | ||
| 684 | 685 | ||||
| 686 | + | `zot ext doctor` keeps normal extension startup fail-soft, but gives | |||
| 687 | + | you an explicit troubleshooting view: manifest errors, disabled or | |||
| 688 | + | shadowed extensions, subprocess load errors, ready/auto-ready status, | |||
| 689 | + | registered commands/tools, registration conflicts, warnings, and the | |||
| 690 | + | stderr log path. | |||
| 691 | + | ||||
| 685 | For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name. | 692 | For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name. | ||
| 686 | 693 | ||||
| 687 | ### Updating extensions | 694 | ### Updating extensions | ||
M docs/extensions.md +7 -0
| @@ -468,6 +468,7 @@ Reply with `shutdown_ack` and then exit. | |||
| 468 | 468 | ||
| 469 | 469 | ``` | |
| 470 | 470 | zot ext list list installed extensions and their state | |
| 471 | + | zot ext doctor diagnose load, registration, and conflict issues | |
| 471 | 472 | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | |
| 472 | 473 | zot ext remove <name> delete an extension directory | |
| 473 | 474 | zot ext enable <name> re-enable a disabled extension | |
| @@ -475,6 +476,12 @@ zot ext disable <name> disable without removing | |||
| 475 | 476 | zot ext logs <name> [-f] cat / tail the extension's stderr | |
| 476 | 477 | ``` | |
| 477 | 478 | ||
| 479 | + | `zot ext doctor` runs the same discovery path as zot startup, but reports | |
| 480 | + | what happened instead of changing the fail-soft runtime behavior. It shows | |
| 481 | + | manifest errors, disabled or shadowed extensions, subprocess load errors, | |
| 482 | + | ready/auto-ready status, registered commands/tools, registration conflicts, | |
| 483 | + | warnings, and each extension's stderr log path. | |
| 484 | + | ||
| 478 | 485 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | |
| 479 | 486 | shallow clone. Both validate that the destination contains an | |
| 480 | 487 | `extension.json` and roll back if not. | |
| @@ -468,6 +468,7 @@ Reply with `shutdown_ack` and then exit. | |||||
| 468 | 468 | ||||
| 469 | ``` | 469 | ``` | ||
| 470 | zot ext list list installed extensions and their state | 470 | zot ext list list installed extensions and their state | ||
| 471 | + | zot ext doctor diagnose load, registration, and conflict issues | |||
| 471 | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | 472 | zot ext install <path|git-url> copy / clone into $ZOT_HOME/extensions/ | ||
| 472 | zot ext remove <name> delete an extension directory | 473 | zot ext remove <name> delete an extension directory | ||
| 473 | zot ext enable <name> re-enable a disabled extension | 474 | zot ext enable <name> re-enable a disabled extension | ||
| @@ -475,6 +476,12 @@ zot ext disable <name> disable without removing | |||||
| 475 | zot ext logs <name> [-f] cat / tail the extension's stderr | 476 | zot ext logs <name> [-f] cat / tail the extension's stderr | ||
| 476 | ``` | 477 | ``` | ||
| 477 | 478 | ||||
| 479 | + | `zot ext doctor` runs the same discovery path as zot startup, but reports | |||
| 480 | + | what happened instead of changing the fail-soft runtime behavior. It shows | |||
| 481 | + | manifest errors, disabled or shadowed extensions, subprocess load errors, | |||
| 482 | + | ready/auto-ready status, registered commands/tools, registration conflicts, | |||
| 483 | + | warnings, and each extension's stderr log path. | |||
| 484 | + | ||||
| 478 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | 485 | `zot ext install <path>` does a recursive copy; `<git-url>` does a | ||
| 479 | shallow clone. Both validate that the destination contains an | 486 | shallow clone. Both validate that the destination contains an | ||
| 480 | `extension.json` and roll back if not. | 487 | `extension.json` and roll back if not. | ||
M packages/agent/cli.go +1 -1
| @@ -183,7 +183,7 @@ func Run(rawArgs []string, version string) error { | |||
| 183 | 183 | if handled, err := runBotCommand(rawArgs, version); handled { | |
| 184 | 184 | return err | |
| 185 | 185 | } | |
| 186 | - | if handled, err := runExtCommand(rawArgs); handled { | |
| 186 | + | if handled, err := runExtCommand(rawArgs, version); handled { | |
| 187 | 187 | return err | |
| 188 | 188 | } | |
| 189 | 189 | if handled, err := runUpdateCommand(rawArgs, version); handled { | |
| @@ -183,7 +183,7 @@ func Run(rawArgs []string, version string) error { | |||||
| 183 | if handled, err := runBotCommand(rawArgs, version); handled { | 183 | if handled, err := runBotCommand(rawArgs, version); handled { | ||
| 184 | return err | 184 | return err | ||
| 185 | } | 185 | } | ||
| 186 | - | if handled, err := runExtCommand(rawArgs); handled { | 186 | + | if handled, err := runExtCommand(rawArgs, version); handled { |
| 187 | return err | 187 | return err | ||
| 188 | } | 188 | } | ||
| 189 | if handled, err := runUpdateCommand(rawArgs, version); handled { | 189 | if handled, err := runUpdateCommand(rawArgs, version); handled { | ||
M packages/agent/extcmd.go +234 -1
| @@ -1,14 +1,19 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "os/exec" | |
| 9 | 10 | "path/filepath" | |
| 11 | + | "sort" | |
| 10 | 12 | "strings" | |
| 13 | + | "time" | |
| 11 | 14 | ||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 16 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 12 | 17 | "github.com/patriceckhart/zot/packages/ignore" | |
| 13 | 18 | ) | |
| 14 | 19 | ||
| @@ -16,7 +21,7 @@ import ( | |||
| 16 | 21 | // (handled=true, err) if rawArgs starts with "ext"; otherwise | |
| 17 | 22 | // (handled=false, nil) so the main router falls through to the | |
| 18 | 23 | // regular flag parser. | |
| 19 | - | func runExtCommand(rawArgs []string) (handled bool, err error) { | |
| 24 | + | func runExtCommand(rawArgs []string, version string) (handled bool, err error) { | |
| 20 | 25 | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | |
| 21 | 26 | return false, nil | |
| 22 | 27 | } | |
| @@ -27,6 +32,8 @@ func runExtCommand(rawArgs []string) (handled bool, err error) { | |||
| 27 | 32 | switch rawArgs[1] { | |
| 28 | 33 | case "list": | |
| 29 | 34 | return true, extList() | |
| 35 | + | case "doctor": | |
| 36 | + | return true, extDoctor(version) | |
| 30 | 37 | case "logs": | |
| 31 | 38 | return true, extLogs(rawArgs[2:]) | |
| 32 | 39 | case "enable": | |
| @@ -51,6 +58,7 @@ func printExtHelp() { | |||
| 51 | 58 | ||
| 52 | 59 | usage: | |
| 53 | 60 | zot ext list list installed extensions and their state | |
| 61 | + | zot ext doctor diagnose installed extensions | |
| 54 | 62 | zot ext logs <name> [-f] cat / tail an extension's stderr log | |
| 55 | 63 | zot ext enable <name> re-enable a disabled extension | |
| 56 | 64 | zot ext disable <name> disable without removing | |
| @@ -122,6 +130,231 @@ func extList() error { | |||
| 122 | 130 | return nil | |
| 123 | 131 | } | |
| 124 | 132 | ||
| 133 | + | type extDoctorHooks struct{} | |
| 134 | + | ||
| 135 | + | func (extDoctorHooks) Notify(string, string, string) {} | |
| 136 | + | func (extDoctorHooks) Submit(string) {} | |
| 137 | + | func (extDoctorHooks) SubmitSlash(string) {} | |
| 138 | + | func (extDoctorHooks) Insert(string) {} | |
| 139 | + | func (extDoctorHooks) Display(string, string) {} | |
| 140 | + | func (extDoctorHooks) ClearNotes(string) {} | |
| 141 | + | func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 142 | + | func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 143 | + | func (extDoctorHooks) ClosePanel(string, string) {} | |
| 144 | + | ||
| 145 | + | type extDoctorStaticRow struct { | |
| 146 | + | Scope string | |
| 147 | + | Name string | |
| 148 | + | Version string | |
| 149 | + | Enabled bool | |
| 150 | + | Dir string | |
| 151 | + | Exec string | |
| 152 | + | Theme bool | |
| 153 | + | Manifest string | |
| 154 | + | Error string | |
| 155 | + | Shadowed bool | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // extDoctor diagnoses extension discovery and registration without changing | |
| 159 | + | // normal fail-soft extension behavior. | |
| 160 | + | func extDoctor(version string) error { | |
| 161 | + | rows := scanExtDoctorStatic() | |
| 162 | + | if len(rows) == 0 { | |
| 163 | + | fmt.Fprintln(os.Stdout, "no extensions installed") | |
| 164 | + | fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zot ext install <path|url>`") | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | cwd, _ := os.Getwd() | |
| 169 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 170 | + | defer cancel() | |
| 171 | + | mgr := extensions.New(ZotHome(), cwd, version, "", "", extDoctorHooks{}) | |
| 172 | + | errs := mgr.Discover(ctx) | |
| 173 | + | mgr.WaitForReady(3 * time.Second) | |
| 174 | + | diags := mgr.Diagnostics() | |
| 175 | + | mgr.Stop(500 * time.Millisecond) | |
| 176 | + | ||
| 177 | + | diagByDir := map[string]extensions.ExtensionDiagnostic{} | |
| 178 | + | for _, d := range diags { | |
| 179 | + | diagByDir[d.Dir] = d | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | fmt.Fprintln(os.Stdout, "zot extension doctor") | |
| 183 | + | fmt.Fprintln(os.Stdout) | |
| 184 | + | for _, row := range rows { | |
| 185 | + | printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir]) | |
| 186 | + | } | |
| 187 | + | if len(errs) > 0 { | |
| 188 | + | fmt.Fprintln(os.Stdout, "load errors:") | |
| 189 | + | for _, err := range errs { | |
| 190 | + | fmt.Fprintf(os.Stdout, " ! %v\n", err) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | return nil | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func scanExtDoctorStatic() []extDoctorStaticRow { | |
| 197 | + | type scanDir struct { | |
| 198 | + | scope string | |
| 199 | + | dir string | |
| 200 | + | } | |
| 201 | + | var dirs []scanDir | |
| 202 | + | if cwd, err := os.Getwd(); err == nil { | |
| 203 | + | dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zot", "extensions")}) | |
| 204 | + | } | |
| 205 | + | if h := ZotHome(); h != "" { | |
| 206 | + | dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")}) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | seen := map[string]bool{} | |
| 210 | + | var rows []extDoctorStaticRow | |
| 211 | + | for _, sd := range dirs { | |
| 212 | + | entries, err := os.ReadDir(sd.dir) | |
| 213 | + | if err != nil { | |
| 214 | + | continue | |
| 215 | + | } | |
| 216 | + | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | |
| 217 | + | for _, e := range entries { | |
| 218 | + | if !e.IsDir() { | |
| 219 | + | continue | |
| 220 | + | } | |
| 221 | + | extDir := filepath.Join(sd.dir, e.Name()) | |
| 222 | + | row := extDoctorStaticRow{ | |
| 223 | + | Scope: sd.scope, | |
| 224 | + | Name: e.Name(), | |
| 225 | + | Enabled: true, | |
| 226 | + | Dir: extDir, | |
| 227 | + | Manifest: filepath.Join(extDir, "extension.json"), | |
| 228 | + | Theme: extensions.HasExtensionTheme(extDir), | |
| 229 | + | } | |
| 230 | + | if seen[e.Name()] { | |
| 231 | + | row.Shadowed = true | |
| 232 | + | } else { | |
| 233 | + | seen[e.Name()] = true | |
| 234 | + | } | |
| 235 | + | raw, err := os.ReadFile(row.Manifest) | |
| 236 | + | if err != nil { | |
| 237 | + | row.Error = "missing extension.json" | |
| 238 | + | rows = append(rows, row) | |
| 239 | + | continue | |
| 240 | + | } | |
| 241 | + | var m struct { | |
| 242 | + | Name string `json:"name"` | |
| 243 | + | Version string `json:"version"` | |
| 244 | + | Exec string `json:"exec"` | |
| 245 | + | Enabled *bool `json:"enabled"` | |
| 246 | + | } | |
| 247 | + | if err := json.Unmarshal(raw, &m); err != nil { | |
| 248 | + | row.Error = "parse manifest: " + err.Error() | |
| 249 | + | rows = append(rows, row) | |
| 250 | + | continue | |
| 251 | + | } | |
| 252 | + | if m.Name != "" { | |
| 253 | + | row.Name = m.Name | |
| 254 | + | } else { | |
| 255 | + | row.Error = "manifest: name is required" | |
| 256 | + | } | |
| 257 | + | row.Version = m.Version | |
| 258 | + | row.Exec = m.Exec | |
| 259 | + | if m.Enabled != nil { | |
| 260 | + | row.Enabled = *m.Enabled | |
| 261 | + | } | |
| 262 | + | if row.Exec == "" && !row.Theme && row.Error == "" { | |
| 263 | + | row.Error = "manifest: exec is required" | |
| 264 | + | } | |
| 265 | + | rows = append(rows, row) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | return rows | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) { | |
| 272 | + | status := "ok" | |
| 273 | + | switch { | |
| 274 | + | case row.Error != "": | |
| 275 | + | status = "error" | |
| 276 | + | case row.Shadowed: | |
| 277 | + | status = "shadowed" | |
| 278 | + | case !row.Enabled: | |
| 279 | + | status = "disabled" | |
| 280 | + | case row.Exec == "" && row.Theme: | |
| 281 | + | status = "theme-only" | |
| 282 | + | case diag.Name == "": | |
| 283 | + | status = "not loaded" | |
| 284 | + | case diag.ReadyTimedOut: | |
| 285 | + | status = "ready-timeout" | |
| 286 | + | case diag.AutoReady: | |
| 287 | + | status = "auto-ready" | |
| 288 | + | case !diag.Ready: | |
| 289 | + | status = "not ready" | |
| 290 | + | case len(diag.Messages) > 0: | |
| 291 | + | status = "warning" | |
| 292 | + | } | |
| 293 | + | fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir) | |
| 294 | + | if row.Version != "" { | |
| 295 | + | fmt.Fprintf(w, " version: %s\n", row.Version) | |
| 296 | + | } | |
| 297 | + | if row.Error != "" { | |
| 298 | + | if row.Exec != "" { | |
| 299 | + | fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name)) | |
| 300 | + | } | |
| 301 | + | fmt.Fprintf(w, " error: %s\n", row.Error) | |
| 302 | + | return | |
| 303 | + | } | |
| 304 | + | if row.Shadowed { | |
| 305 | + | fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins") | |
| 306 | + | return | |
| 307 | + | } | |
| 308 | + | if !row.Enabled { | |
| 309 | + | fmt.Fprintln(w, " note: disabled in extension.json") | |
| 310 | + | return | |
| 311 | + | } | |
| 312 | + | if row.Exec == "" && row.Theme { | |
| 313 | + | fmt.Fprintln(w, " type: theme-only") | |
| 314 | + | return | |
| 315 | + | } | |
| 316 | + | if row.Exec != "" { | |
| 317 | + | fmt.Fprintf(w, " exec: %s\n", row.Exec) | |
| 318 | + | } | |
| 319 | + | logPath := diag.LogPath | |
| 320 | + | if logPath == "" && row.Exec != "" { | |
| 321 | + | logPath = extDoctorLogPath(row.Name) | |
| 322 | + | } | |
| 323 | + | if logPath != "" { | |
| 324 | + | fmt.Fprintf(w, " log: %s\n", logPath) | |
| 325 | + | } | |
| 326 | + | if len(diag.Commands) > 0 { | |
| 327 | + | fmt.Fprintln(w, " commands:") | |
| 328 | + | for _, c := range diag.Commands { | |
| 329 | + | state := "active" | |
| 330 | + | if !c.Active { | |
| 331 | + | state = "shadowed" | |
| 332 | + | } | |
| 333 | + | fmt.Fprintf(w, " /%s (%s)\n", c.Name, state) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | if len(diag.Tools) > 0 { | |
| 337 | + | fmt.Fprintln(w, " tools:") | |
| 338 | + | for _, t := range diag.Tools { | |
| 339 | + | state := "active" | |
| 340 | + | if !t.Active { | |
| 341 | + | state = "shadowed" | |
| 342 | + | } | |
| 343 | + | fmt.Fprintf(w, " %s (%s)\n", t.Name, state) | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | for _, msg := range diag.Messages { | |
| 347 | + | fmt.Fprintf(w, " warning: %s\n", msg) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func extDoctorLogPath(name string) string { | |
| 352 | + | if name == "" || ZotHome() == "" { | |
| 353 | + | return "" | |
| 354 | + | } | |
| 355 | + | return filepath.Join(ZotHome(), "logs", "ext-"+name+".log") | |
| 356 | + | } | |
| 357 | + | ||
| 125 | 358 | // extLogs locates the named extension's log file and either cats or | |
| 126 | 359 | // tails it (-f). | |
| 127 | 360 | func extLogs(args []string) error { | |
| @@ -1,14 +1,19 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "io" | 7 | "io" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "os/exec" | 9 | "os/exec" | ||
| 9 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | + | "sort" | |||
| 10 | "strings" | 12 | "strings" | ||
| 13 | + | "time" | |||
| 11 | 14 | ||||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |||
| 16 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 12 | "github.com/patriceckhart/zot/packages/ignore" | 17 | "github.com/patriceckhart/zot/packages/ignore" | ||
| 13 | ) | 18 | ) | ||
| 14 | 19 | ||||
| @@ -16,7 +21,7 @@ import ( | |||||
| 16 | // (handled=true, err) if rawArgs starts with "ext"; otherwise | 21 | // (handled=true, err) if rawArgs starts with "ext"; otherwise | ||
| 17 | // (handled=false, nil) so the main router falls through to the | 22 | // (handled=false, nil) so the main router falls through to the | ||
| 18 | // regular flag parser. | 23 | // regular flag parser. | ||
| 19 | - | func runExtCommand(rawArgs []string) (handled bool, err error) { | 24 | + | func runExtCommand(rawArgs []string, version string) (handled bool, err error) { |
| 20 | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | 25 | if len(rawArgs) == 0 || rawArgs[0] != "ext" { | ||
| 21 | return false, nil | 26 | return false, nil | ||
| 22 | } | 27 | } | ||
| @@ -27,6 +32,8 @@ func runExtCommand(rawArgs []string) (handled bool, err error) { | |||||
| 27 | switch rawArgs[1] { | 32 | switch rawArgs[1] { | ||
| 28 | case "list": | 33 | case "list": | ||
| 29 | return true, extList() | 34 | return true, extList() | ||
| 35 | + | case "doctor": | |||
| 36 | + | return true, extDoctor(version) | |||
| 30 | case "logs": | 37 | case "logs": | ||
| 31 | return true, extLogs(rawArgs[2:]) | 38 | return true, extLogs(rawArgs[2:]) | ||
| 32 | case "enable": | 39 | case "enable": | ||
| @@ -51,6 +58,7 @@ func printExtHelp() { | |||||
| 51 | 58 | ||||
| 52 | usage: | 59 | usage: | ||
| 53 | zot ext list list installed extensions and their state | 60 | zot ext list list installed extensions and their state | ||
| 61 | + | zot ext doctor diagnose installed extensions | |||
| 54 | zot ext logs <name> [-f] cat / tail an extension's stderr log | 62 | zot ext logs <name> [-f] cat / tail an extension's stderr log | ||
| 55 | zot ext enable <name> re-enable a disabled extension | 63 | zot ext enable <name> re-enable a disabled extension | ||
| 56 | zot ext disable <name> disable without removing | 64 | zot ext disable <name> disable without removing | ||
| @@ -122,6 +130,231 @@ func extList() error { | |||||
| 122 | return nil | 130 | return nil | ||
| 123 | } | 131 | } | ||
| 124 | 132 | ||||
| 133 | + | type extDoctorHooks struct{} | |||
| 134 | + | ||||
| 135 | + | func (extDoctorHooks) Notify(string, string, string) {} | |||
| 136 | + | func (extDoctorHooks) Submit(string) {} | |||
| 137 | + | func (extDoctorHooks) SubmitSlash(string) {} | |||
| 138 | + | func (extDoctorHooks) Insert(string) {} | |||
| 139 | + | func (extDoctorHooks) Display(string, string) {} | |||
| 140 | + | func (extDoctorHooks) ClearNotes(string) {} | |||
| 141 | + | func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {} | |||
| 142 | + | func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {} | |||
| 143 | + | func (extDoctorHooks) ClosePanel(string, string) {} | |||
| 144 | + | ||||
| 145 | + | type extDoctorStaticRow struct { | |||
| 146 | + | Scope string | |||
| 147 | + | Name string | |||
| 148 | + | Version string | |||
| 149 | + | Enabled bool | |||
| 150 | + | Dir string | |||
| 151 | + | Exec string | |||
| 152 | + | Theme bool | |||
| 153 | + | Manifest string | |||
| 154 | + | Error string | |||
| 155 | + | Shadowed bool | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // extDoctor diagnoses extension discovery and registration without changing | |||
| 159 | + | // normal fail-soft extension behavior. | |||
| 160 | + | func extDoctor(version string) error { | |||
| 161 | + | rows := scanExtDoctorStatic() | |||
| 162 | + | if len(rows) == 0 { | |||
| 163 | + | fmt.Fprintln(os.Stdout, "no extensions installed") | |||
| 164 | + | fmt.Fprintln(os.Stdout, "see docs/extensions.md to write your own, or `zot ext install <path|url>`") | |||
| 165 | + | return nil | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | cwd, _ := os.Getwd() | |||
| 169 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 170 | + | defer cancel() | |||
| 171 | + | mgr := extensions.New(ZotHome(), cwd, version, "", "", extDoctorHooks{}) | |||
| 172 | + | errs := mgr.Discover(ctx) | |||
| 173 | + | mgr.WaitForReady(3 * time.Second) | |||
| 174 | + | diags := mgr.Diagnostics() | |||
| 175 | + | mgr.Stop(500 * time.Millisecond) | |||
| 176 | + | ||||
| 177 | + | diagByDir := map[string]extensions.ExtensionDiagnostic{} | |||
| 178 | + | for _, d := range diags { | |||
| 179 | + | diagByDir[d.Dir] = d | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | fmt.Fprintln(os.Stdout, "zot extension doctor") | |||
| 183 | + | fmt.Fprintln(os.Stdout) | |||
| 184 | + | for _, row := range rows { | |||
| 185 | + | printExtDoctorRow(os.Stdout, row, diagByDir[row.Dir]) | |||
| 186 | + | } | |||
| 187 | + | if len(errs) > 0 { | |||
| 188 | + | fmt.Fprintln(os.Stdout, "load errors:") | |||
| 189 | + | for _, err := range errs { | |||
| 190 | + | fmt.Fprintf(os.Stdout, " ! %v\n", err) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | return nil | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func scanExtDoctorStatic() []extDoctorStaticRow { | |||
| 197 | + | type scanDir struct { | |||
| 198 | + | scope string | |||
| 199 | + | dir string | |||
| 200 | + | } | |||
| 201 | + | var dirs []scanDir | |||
| 202 | + | if cwd, err := os.Getwd(); err == nil { | |||
| 203 | + | dirs = append(dirs, scanDir{scope: "project", dir: filepath.Join(cwd, ".zot", "extensions")}) | |||
| 204 | + | } | |||
| 205 | + | if h := ZotHome(); h != "" { | |||
| 206 | + | dirs = append(dirs, scanDir{scope: "global", dir: filepath.Join(h, "extensions")}) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | seen := map[string]bool{} | |||
| 210 | + | var rows []extDoctorStaticRow | |||
| 211 | + | for _, sd := range dirs { | |||
| 212 | + | entries, err := os.ReadDir(sd.dir) | |||
| 213 | + | if err != nil { | |||
| 214 | + | continue | |||
| 215 | + | } | |||
| 216 | + | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | |||
| 217 | + | for _, e := range entries { | |||
| 218 | + | if !e.IsDir() { | |||
| 219 | + | continue | |||
| 220 | + | } | |||
| 221 | + | extDir := filepath.Join(sd.dir, e.Name()) | |||
| 222 | + | row := extDoctorStaticRow{ | |||
| 223 | + | Scope: sd.scope, | |||
| 224 | + | Name: e.Name(), | |||
| 225 | + | Enabled: true, | |||
| 226 | + | Dir: extDir, | |||
| 227 | + | Manifest: filepath.Join(extDir, "extension.json"), | |||
| 228 | + | Theme: extensions.HasExtensionTheme(extDir), | |||
| 229 | + | } | |||
| 230 | + | if seen[e.Name()] { | |||
| 231 | + | row.Shadowed = true | |||
| 232 | + | } else { | |||
| 233 | + | seen[e.Name()] = true | |||
| 234 | + | } | |||
| 235 | + | raw, err := os.ReadFile(row.Manifest) | |||
| 236 | + | if err != nil { | |||
| 237 | + | row.Error = "missing extension.json" | |||
| 238 | + | rows = append(rows, row) | |||
| 239 | + | continue | |||
| 240 | + | } | |||
| 241 | + | var m struct { | |||
| 242 | + | Name string `json:"name"` | |||
| 243 | + | Version string `json:"version"` | |||
| 244 | + | Exec string `json:"exec"` | |||
| 245 | + | Enabled *bool `json:"enabled"` | |||
| 246 | + | } | |||
| 247 | + | if err := json.Unmarshal(raw, &m); err != nil { | |||
| 248 | + | row.Error = "parse manifest: " + err.Error() | |||
| 249 | + | rows = append(rows, row) | |||
| 250 | + | continue | |||
| 251 | + | } | |||
| 252 | + | if m.Name != "" { | |||
| 253 | + | row.Name = m.Name | |||
| 254 | + | } else { | |||
| 255 | + | row.Error = "manifest: name is required" | |||
| 256 | + | } | |||
| 257 | + | row.Version = m.Version | |||
| 258 | + | row.Exec = m.Exec | |||
| 259 | + | if m.Enabled != nil { | |||
| 260 | + | row.Enabled = *m.Enabled | |||
| 261 | + | } | |||
| 262 | + | if row.Exec == "" && !row.Theme && row.Error == "" { | |||
| 263 | + | row.Error = "manifest: exec is required" | |||
| 264 | + | } | |||
| 265 | + | rows = append(rows, row) | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | return rows | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func printExtDoctorRow(w io.Writer, row extDoctorStaticRow, diag extensions.ExtensionDiagnostic) { | |||
| 272 | + | status := "ok" | |||
| 273 | + | switch { | |||
| 274 | + | case row.Error != "": | |||
| 275 | + | status = "error" | |||
| 276 | + | case row.Shadowed: | |||
| 277 | + | status = "shadowed" | |||
| 278 | + | case !row.Enabled: | |||
| 279 | + | status = "disabled" | |||
| 280 | + | case row.Exec == "" && row.Theme: | |||
| 281 | + | status = "theme-only" | |||
| 282 | + | case diag.Name == "": | |||
| 283 | + | status = "not loaded" | |||
| 284 | + | case diag.ReadyTimedOut: | |||
| 285 | + | status = "ready-timeout" | |||
| 286 | + | case diag.AutoReady: | |||
| 287 | + | status = "auto-ready" | |||
| 288 | + | case !diag.Ready: | |||
| 289 | + | status = "not ready" | |||
| 290 | + | case len(diag.Messages) > 0: | |||
| 291 | + | status = "warning" | |||
| 292 | + | } | |||
| 293 | + | fmt.Fprintf(w, "%s [%s] %s (%s)\n", row.Name, row.Scope, status, row.Dir) | |||
| 294 | + | if row.Version != "" { | |||
| 295 | + | fmt.Fprintf(w, " version: %s\n", row.Version) | |||
| 296 | + | } | |||
| 297 | + | if row.Error != "" { | |||
| 298 | + | if row.Exec != "" { | |||
| 299 | + | fmt.Fprintf(w, " log: %s\n", extDoctorLogPath(row.Name)) | |||
| 300 | + | } | |||
| 301 | + | fmt.Fprintf(w, " error: %s\n", row.Error) | |||
| 302 | + | return | |||
| 303 | + | } | |||
| 304 | + | if row.Shadowed { | |||
| 305 | + | fmt.Fprintln(w, " note: skipped because a higher-priority extension directory with this name wins") | |||
| 306 | + | return | |||
| 307 | + | } | |||
| 308 | + | if !row.Enabled { | |||
| 309 | + | fmt.Fprintln(w, " note: disabled in extension.json") | |||
| 310 | + | return | |||
| 311 | + | } | |||
| 312 | + | if row.Exec == "" && row.Theme { | |||
| 313 | + | fmt.Fprintln(w, " type: theme-only") | |||
| 314 | + | return | |||
| 315 | + | } | |||
| 316 | + | if row.Exec != "" { | |||
| 317 | + | fmt.Fprintf(w, " exec: %s\n", row.Exec) | |||
| 318 | + | } | |||
| 319 | + | logPath := diag.LogPath | |||
| 320 | + | if logPath == "" && row.Exec != "" { | |||
| 321 | + | logPath = extDoctorLogPath(row.Name) | |||
| 322 | + | } | |||
| 323 | + | if logPath != "" { | |||
| 324 | + | fmt.Fprintf(w, " log: %s\n", logPath) | |||
| 325 | + | } | |||
| 326 | + | if len(diag.Commands) > 0 { | |||
| 327 | + | fmt.Fprintln(w, " commands:") | |||
| 328 | + | for _, c := range diag.Commands { | |||
| 329 | + | state := "active" | |||
| 330 | + | if !c.Active { | |||
| 331 | + | state = "shadowed" | |||
| 332 | + | } | |||
| 333 | + | fmt.Fprintf(w, " /%s (%s)\n", c.Name, state) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | if len(diag.Tools) > 0 { | |||
| 337 | + | fmt.Fprintln(w, " tools:") | |||
| 338 | + | for _, t := range diag.Tools { | |||
| 339 | + | state := "active" | |||
| 340 | + | if !t.Active { | |||
| 341 | + | state = "shadowed" | |||
| 342 | + | } | |||
| 343 | + | fmt.Fprintf(w, " %s (%s)\n", t.Name, state) | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | for _, msg := range diag.Messages { | |||
| 347 | + | fmt.Fprintf(w, " warning: %s\n", msg) | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func extDoctorLogPath(name string) string { | |||
| 352 | + | if name == "" || ZotHome() == "" { | |||
| 353 | + | return "" | |||
| 354 | + | } | |||
| 355 | + | return filepath.Join(ZotHome(), "logs", "ext-"+name+".log") | |||
| 356 | + | } | |||
| 357 | + | ||||
| 125 | // extLogs locates the named extension's log file and either cats or | 358 | // extLogs locates the named extension's log file and either cats or | ||
| 126 | // tails it (-f). | 359 | // tails it (-f). | ||
| 127 | func extLogs(args []string) error { | 360 | func extLogs(args []string) error { | ||
M packages/agent/extcmd_test.go +57 -0
| @@ -1,9 +1,13 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 7 | + | "strings" | |
| 6 | 8 | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 7 | 11 | ) | |
| 8 | 12 | ||
| 9 | 13 | // TestExtInstallDotSource verifies that `zot ext install .` derives the | |
| @@ -124,3 +128,56 @@ func TestGitignoreNegation(t *testing.T) { | |||
| 124 | 128 | t.Fatal("expected build/keep.txt to be re-included by negation") | |
| 125 | 129 | } | |
| 126 | 130 | } | |
| 131 | + | ||
| 132 | + | func TestExtDoctorStaticScanAndRender(t *testing.T) { | |
| 133 | + | home := t.TempDir() | |
| 134 | + | t.Setenv("ZOT_HOME", home) | |
| 135 | + | cwd := t.TempDir() | |
| 136 | + | oldCWD, err := os.Getwd() | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | defer os.Chdir(oldCWD) | |
| 141 | + | if err := os.Chdir(cwd); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | mustWrite := func(path, body string) { | |
| 146 | + | t.Helper() | |
| 147 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 149 | + | } | |
| 150 | + | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 151 | + | t.Fatal(err) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`) | |
| 156 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`) | |
| 157 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "theme.json"), `{"name":"Theme"}`) | |
| 158 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`) | |
| 159 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`) | |
| 160 | + | mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`) | |
| 161 | + | ||
| 162 | + | rows := scanExtDoctorStatic() | |
| 163 | + | if len(rows) != 5 { | |
| 164 | + | t.Fatalf("rows = %d, want 5: %#v", len(rows), rows) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | var out bytes.Buffer | |
| 168 | + | for _, row := range rows { | |
| 169 | + | printExtDoctorRow(&out, row, extensions.ExtensionDiagnostic{}) | |
| 170 | + | } | |
| 171 | + | got := out.String() | |
| 172 | + | for _, want := range []string{ | |
| 173 | + | "disabled [project] disabled", | |
| 174 | + | "theme [project] theme-only", | |
| 175 | + | "bad [project] error", | |
| 176 | + | "dup [global] shadowed", | |
| 177 | + | "parse manifest:", | |
| 178 | + | } { | |
| 179 | + | if !strings.Contains(got, want) { | |
| 180 | + | t.Fatalf("doctor output missing %q:\n%s", want, got) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| @@ -1,9 +1,13 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | + | "strings" | |||
| 6 | "testing" | 8 | "testing" | ||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |||
| 7 | ) | 11 | ) | ||
| 8 | 12 | ||||
| 9 | // TestExtInstallDotSource verifies that `zot ext install .` derives the | 13 | // TestExtInstallDotSource verifies that `zot ext install .` derives the | ||
| @@ -124,3 +128,56 @@ func TestGitignoreNegation(t *testing.T) { | |||||
| 124 | t.Fatal("expected build/keep.txt to be re-included by negation") | 128 | t.Fatal("expected build/keep.txt to be re-included by negation") | ||
| 125 | } | 129 | } | ||
| 126 | } | 130 | } | ||
| 131 | + | ||||
| 132 | + | func TestExtDoctorStaticScanAndRender(t *testing.T) { | |||
| 133 | + | home := t.TempDir() | |||
| 134 | + | t.Setenv("ZOT_HOME", home) | |||
| 135 | + | cwd := t.TempDir() | |||
| 136 | + | oldCWD, err := os.Getwd() | |||
| 137 | + | if err != nil { | |||
| 138 | + | t.Fatal(err) | |||
| 139 | + | } | |||
| 140 | + | defer os.Chdir(oldCWD) | |||
| 141 | + | if err := os.Chdir(cwd); err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | mustWrite := func(path, body string) { | |||
| 146 | + | t.Helper() | |||
| 147 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 148 | + | t.Fatal(err) | |||
| 149 | + | } | |||
| 150 | + | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |||
| 151 | + | t.Fatal(err) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`) | |||
| 156 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "extension.json"), `{"name":"theme"}`) | |||
| 157 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "theme", "theme.json"), `{"name":"Theme"}`) | |||
| 158 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "bad", "extension.json"), `{bad json`) | |||
| 159 | + | mustWrite(filepath.Join(cwd, ".zot", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`) | |||
| 160 | + | mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`) | |||
| 161 | + | ||||
| 162 | + | rows := scanExtDoctorStatic() | |||
| 163 | + | if len(rows) != 5 { | |||
| 164 | + | t.Fatalf("rows = %d, want 5: %#v", len(rows), rows) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | var out bytes.Buffer | |||
| 168 | + | for _, row := range rows { | |||
| 169 | + | printExtDoctorRow(&out, row, extensions.ExtensionDiagnostic{}) | |||
| 170 | + | } | |||
| 171 | + | got := out.String() | |||
| 172 | + | for _, want := range []string{ | |||
| 173 | + | "disabled [project] disabled", | |||
| 174 | + | "theme [project] theme-only", | |||
| 175 | + | "bad [project] error", | |||
| 176 | + | "dup [global] shadowed", | |||
| 177 | + | "parse manifest:", | |||
| 178 | + | } { | |||
| 179 | + | if !strings.Contains(got, want) { | |||
| 180 | + | t.Fatalf("doctor output missing %q:\n%s", want, got) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | } | |||
M packages/agent/extensions/manager.go +119 -4
| @@ -92,6 +92,10 @@ type Extension struct { | |||
| 92 | 92 | // EmitEvent / InterceptToolCall to filter recipients. | |
| 93 | 93 | eventSubs map[string]struct{} | |
| 94 | 94 | interceptSubs map[string]struct{} | |
| 95 | + | ||
| 96 | + | autoReady bool | |
| 97 | + | readyTimedOut bool | |
| 98 | + | diagnostics []string | |
| 95 | 99 | } | |
| 96 | 100 | ||
| 97 | 101 | // HostHooks is the small interface the manager calls back into the | |
| @@ -255,7 +259,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 255 | 259 | if mf.Name == "" { | |
| 256 | 260 | return errors.New("manifest: name is required") | |
| 257 | 261 | } | |
| 258 | - | hasTheme := hasExtensionTheme(dir) | |
| 262 | + | hasTheme := HasExtensionTheme(dir) | |
| 259 | 263 | if mf.Exec == "" && !hasTheme { | |
| 260 | 264 | return errors.New("manifest: exec is required") | |
| 261 | 265 | } | |
| @@ -307,7 +311,9 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 307 | 311 | // Loaded BEFORE Discover so explicit paths win on name conflicts | |
| 308 | 312 | // against installed extensions. Spawns happen in parallel like the | |
| 309 | 313 | // regular discovery path; errors are returned per path. | |
| 310 | - | func hasExtensionTheme(dir string) bool { | |
| 314 | + | // HasExtensionTheme reports whether an extension directory contains a theme | |
| 315 | + | // file in one of the supported extension-owned locations. | |
| 316 | + | func HasExtensionTheme(dir string) bool { | |
| 311 | 317 | for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} { | |
| 312 | 318 | if _, err := os.Stat(filepath.Join(dir, file)); err == nil { | |
| 313 | 319 | return true | |
| @@ -490,6 +496,10 @@ func (m *Manager) WaitForReady(grace time.Duration) { | |||
| 490 | 496 | select { | |
| 491 | 497 | case <-ext.readyCh: | |
| 492 | 498 | case <-deadline: | |
| 499 | + | ext.mu.Lock() | |
| 500 | + | ext.readyTimedOut = true | |
| 501 | + | ext.diagnostics = append(ext.diagnostics, "timed out waiting for ready frame") | |
| 502 | + | ext.mu.Unlock() | |
| 493 | 503 | fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n") | |
| 494 | 504 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | |
| 495 | 505 | } | |
| @@ -623,6 +633,10 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) { | |||
| 623 | 633 | if current.Equal(last) { | |
| 624 | 634 | // No new frame in the idle window. Treat as ready. | |
| 625 | 635 | ext.readyOnce.Do(func() { | |
| 636 | + | ext.mu.Lock() | |
| 637 | + | ext.autoReady = true | |
| 638 | + | ext.diagnostics = append(ext.diagnostics, "no ready frame; auto-ready after idle") | |
| 639 | + | ext.mu.Unlock() | |
| 626 | 640 | fmt.Fprintf(ext.logFile, "[zot] no ready frame; auto-readying after idle (legacy SDK?)\n") | |
| 627 | 641 | close(ext.readyCh) | |
| 628 | 642 | }) | |
| @@ -662,6 +676,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 662 | 676 | ext.mu.Unlock() | |
| 663 | 677 | var frame extproto.Frame | |
| 664 | 678 | if err := json.Unmarshal(line, &frame); err != nil { | |
| 679 | + | ext.recordDiagnostic(fmt.Sprintf("malformed json frame: %v", err)) | |
| 665 | 680 | fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err) | |
| 666 | 681 | continue | |
| 667 | 682 | } | |
| @@ -675,10 +690,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 675 | 690 | // keep the race detector happy. | |
| 676 | 691 | m.mu.Lock() | |
| 677 | 692 | ext.commands = append(ext.commands, rc) | |
| 678 | - | if _, exists := m.commandIndex[rc.Name]; !exists { | |
| 693 | + | _, shadowed := m.commandIndex[rc.Name] | |
| 694 | + | if !shadowed { | |
| 679 | 695 | m.commandIndex[rc.Name] = ext | |
| 680 | 696 | } | |
| 681 | 697 | m.mu.Unlock() | |
| 698 | + | if shadowed { | |
| 699 | + | ext.recordDiagnostic(fmt.Sprintf("command /%s conflicts with another extension registering the same name", rc.Name)) | |
| 700 | + | } | |
| 682 | 701 | } | |
| 683 | 702 | case "register_tool": | |
| 684 | 703 | var rt extproto.RegisterToolFromExt | |
| @@ -691,16 +710,21 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 691 | 710 | if len(rt.Schema) > 0 { | |
| 692 | 711 | var tmp any | |
| 693 | 712 | if err := json.Unmarshal(rt.Schema, &tmp); err != nil { | |
| 713 | + | ext.recordDiagnostic(fmt.Sprintf("tool %q schema is not valid json: %v", rt.Name, err)) | |
| 694 | 714 | fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err) | |
| 695 | 715 | continue | |
| 696 | 716 | } | |
| 697 | 717 | } | |
| 698 | 718 | m.mu.Lock() | |
| 699 | 719 | ext.tools = append(ext.tools, rt) | |
| 700 | - | if _, exists := m.toolIndex[rt.Name]; !exists { | |
| 720 | + | _, shadowed := m.toolIndex[rt.Name] | |
| 721 | + | if !shadowed { | |
| 701 | 722 | m.toolIndex[rt.Name] = ext | |
| 702 | 723 | } | |
| 703 | 724 | m.mu.Unlock() | |
| 725 | + | if shadowed { | |
| 726 | + | ext.recordDiagnostic(fmt.Sprintf("tool %s conflicts with another extension registering the same name", rt.Name)) | |
| 727 | + | } | |
| 704 | 728 | case "ready": | |
| 705 | 729 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | |
| 706 | 730 | case "subscribe": | |
| @@ -805,11 +829,102 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 805 | 829 | case "shutdown_ack": | |
| 806 | 830 | // Caller of Stop is waiting on the process exit, not this frame. | |
| 807 | 831 | default: | |
| 832 | + | ext.recordDiagnostic(fmt.Sprintf("unknown frame type %q", frame.Type)) | |
| 808 | 833 | fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type) | |
| 809 | 834 | } | |
| 810 | 835 | } | |
| 811 | 836 | } | |
| 812 | 837 | ||
| 838 | + | func (ext *Extension) recordDiagnostic(msg string) { | |
| 839 | + | ext.mu.Lock() | |
| 840 | + | defer ext.mu.Unlock() | |
| 841 | + | ext.diagnostics = append(ext.diagnostics, msg) | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | // RegisteredCommandDiagnostic describes one command an extension attempted | |
| 845 | + | // to register and whether it owns the active dispatch slot. | |
| 846 | + | type RegisteredCommandDiagnostic struct { | |
| 847 | + | Name string | |
| 848 | + | Description string | |
| 849 | + | Active bool | |
| 850 | + | } | |
| 851 | + | ||
| 852 | + | // RegisteredToolDiagnostic describes one tool an extension attempted to | |
| 853 | + | // register and whether it owns the active dispatch slot. | |
| 854 | + | type RegisteredToolDiagnostic struct { | |
| 855 | + | Name string | |
| 856 | + | Description string | |
| 857 | + | Active bool | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | // ExtensionDiagnostic is a stable snapshot for diagnostic commands. | |
| 861 | + | type ExtensionDiagnostic struct { | |
| 862 | + | Name string | |
| 863 | + | Version string | |
| 864 | + | Dir string | |
| 865 | + | LogPath string | |
| 866 | + | ThemeOnly bool | |
| 867 | + | Ready bool | |
| 868 | + | AutoReady bool | |
| 869 | + | ReadyTimedOut bool | |
| 870 | + | Commands []RegisteredCommandDiagnostic | |
| 871 | + | Tools []RegisteredToolDiagnostic | |
| 872 | + | Messages []string | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | // Diagnostics returns a snapshot of loaded extension state for commands such | |
| 876 | + | // as `zot ext doctor`. It is read-only and does not change manager behavior. | |
| 877 | + | func (m *Manager) Diagnostics() []ExtensionDiagnostic { | |
| 878 | + | m.mu.RLock() | |
| 879 | + | defer m.mu.RUnlock() | |
| 880 | + | names := make([]string, 0, len(m.ext)) | |
| 881 | + | for name := range m.ext { | |
| 882 | + | names = append(names, name) | |
| 883 | + | } | |
| 884 | + | sort.Strings(names) | |
| 885 | + | out := make([]ExtensionDiagnostic, 0, len(names)) | |
| 886 | + | for _, name := range names { | |
| 887 | + | ext := m.ext[name] | |
| 888 | + | ext.mu.Lock() | |
| 889 | + | msgs := append([]string(nil), ext.diagnostics...) | |
| 890 | + | autoReady := ext.autoReady | |
| 891 | + | readyTimedOut := ext.readyTimedOut | |
| 892 | + | ext.mu.Unlock() | |
| 893 | + | ||
| 894 | + | diag := ExtensionDiagnostic{ | |
| 895 | + | Name: ext.Manifest.Name, | |
| 896 | + | Version: ext.Manifest.Version, | |
| 897 | + | Dir: ext.Dir, | |
| 898 | + | LogPath: ext.LogPath, | |
| 899 | + | ThemeOnly: ext.Manifest.Exec == "", | |
| 900 | + | AutoReady: autoReady, | |
| 901 | + | ReadyTimedOut: readyTimedOut, | |
| 902 | + | Messages: msgs, | |
| 903 | + | } | |
| 904 | + | select { | |
| 905 | + | case <-ext.readyCh: | |
| 906 | + | diag.Ready = true | |
| 907 | + | default: | |
| 908 | + | } | |
| 909 | + | for _, c := range ext.commands { | |
| 910 | + | diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{ | |
| 911 | + | Name: c.Name, | |
| 912 | + | Description: c.Description, | |
| 913 | + | Active: m.commandIndex[c.Name] == ext, | |
| 914 | + | }) | |
| 915 | + | } | |
| 916 | + | for _, t := range ext.tools { | |
| 917 | + | diag.Tools = append(diag.Tools, RegisteredToolDiagnostic{ | |
| 918 | + | Name: t.Name, | |
| 919 | + | Description: t.Description, | |
| 920 | + | Active: m.toolIndex[t.Name] == ext, | |
| 921 | + | }) | |
| 922 | + | } | |
| 923 | + | out = append(out, diag) | |
| 924 | + | } | |
| 925 | + | return out | |
| 926 | + | } | |
| 927 | + | ||
| 813 | 928 | // Commands returns a snapshot of every (extension, command) pair | |
| 814 | 929 | // currently registered. Used by the slash autocomplete + /help. | |
| 815 | 930 | func (m *Manager) Commands() []CommandInfo { | |
| @@ -92,6 +92,10 @@ type Extension struct { | |||||
| 92 | // EmitEvent / InterceptToolCall to filter recipients. | 92 | // EmitEvent / InterceptToolCall to filter recipients. | ||
| 93 | eventSubs map[string]struct{} | 93 | eventSubs map[string]struct{} | ||
| 94 | interceptSubs map[string]struct{} | 94 | interceptSubs map[string]struct{} | ||
| 95 | + | ||||
| 96 | + | autoReady bool | |||
| 97 | + | readyTimedOut bool | |||
| 98 | + | diagnostics []string | |||
| 95 | } | 99 | } | ||
| 96 | 100 | ||||
| 97 | // HostHooks is the small interface the manager calls back into the | 101 | // HostHooks is the small interface the manager calls back into the | ||
| @@ -255,7 +259,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||||
| 255 | if mf.Name == "" { | 259 | if mf.Name == "" { | ||
| 256 | return errors.New("manifest: name is required") | 260 | return errors.New("manifest: name is required") | ||
| 257 | } | 261 | } | ||
| 258 | - | hasTheme := hasExtensionTheme(dir) | 262 | + | hasTheme := HasExtensionTheme(dir) |
| 259 | if mf.Exec == "" && !hasTheme { | 263 | if mf.Exec == "" && !hasTheme { | ||
| 260 | return errors.New("manifest: exec is required") | 264 | return errors.New("manifest: exec is required") | ||
| 261 | } | 265 | } | ||
| @@ -307,7 +311,9 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||||
| 307 | // Loaded BEFORE Discover so explicit paths win on name conflicts | 311 | // Loaded BEFORE Discover so explicit paths win on name conflicts | ||
| 308 | // against installed extensions. Spawns happen in parallel like the | 312 | // against installed extensions. Spawns happen in parallel like the | ||
| 309 | // regular discovery path; errors are returned per path. | 313 | // regular discovery path; errors are returned per path. | ||
| 310 | - | func hasExtensionTheme(dir string) bool { | 314 | + | // HasExtensionTheme reports whether an extension directory contains a theme |
| 315 | + | // file in one of the supported extension-owned locations. | |||
| 316 | + | func HasExtensionTheme(dir string) bool { | |||
| 311 | for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} { | 317 | for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} { | ||
| 312 | if _, err := os.Stat(filepath.Join(dir, file)); err == nil { | 318 | if _, err := os.Stat(filepath.Join(dir, file)); err == nil { | ||
| 313 | return true | 319 | return true | ||
| @@ -490,6 +496,10 @@ func (m *Manager) WaitForReady(grace time.Duration) { | |||||
| 490 | select { | 496 | select { | ||
| 491 | case <-ext.readyCh: | 497 | case <-ext.readyCh: | ||
| 492 | case <-deadline: | 498 | case <-deadline: | ||
| 499 | + | ext.mu.Lock() | |||
| 500 | + | ext.readyTimedOut = true | |||
| 501 | + | ext.diagnostics = append(ext.diagnostics, "timed out waiting for ready frame") | |||
| 502 | + | ext.mu.Unlock() | |||
| 493 | fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n") | 503 | fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n") | ||
| 494 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | 504 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | ||
| 495 | } | 505 | } | ||
| @@ -623,6 +633,10 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) { | |||||
| 623 | if current.Equal(last) { | 633 | if current.Equal(last) { | ||
| 624 | // No new frame in the idle window. Treat as ready. | 634 | // No new frame in the idle window. Treat as ready. | ||
| 625 | ext.readyOnce.Do(func() { | 635 | ext.readyOnce.Do(func() { | ||
| 636 | + | ext.mu.Lock() | |||
| 637 | + | ext.autoReady = true | |||
| 638 | + | ext.diagnostics = append(ext.diagnostics, "no ready frame; auto-ready after idle") | |||
| 639 | + | ext.mu.Unlock() | |||
| 626 | fmt.Fprintf(ext.logFile, "[zot] no ready frame; auto-readying after idle (legacy SDK?)\n") | 640 | fmt.Fprintf(ext.logFile, "[zot] no ready frame; auto-readying after idle (legacy SDK?)\n") | ||
| 627 | close(ext.readyCh) | 641 | close(ext.readyCh) | ||
| 628 | }) | 642 | }) | ||
| @@ -662,6 +676,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 662 | ext.mu.Unlock() | 676 | ext.mu.Unlock() | ||
| 663 | var frame extproto.Frame | 677 | var frame extproto.Frame | ||
| 664 | if err := json.Unmarshal(line, &frame); err != nil { | 678 | if err := json.Unmarshal(line, &frame); err != nil { | ||
| 679 | + | ext.recordDiagnostic(fmt.Sprintf("malformed json frame: %v", err)) | |||
| 665 | fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err) | 680 | fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err) | ||
| 666 | continue | 681 | continue | ||
| 667 | } | 682 | } | ||
| @@ -675,10 +690,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 675 | // keep the race detector happy. | 690 | // keep the race detector happy. | ||
| 676 | m.mu.Lock() | 691 | m.mu.Lock() | ||
| 677 | ext.commands = append(ext.commands, rc) | 692 | ext.commands = append(ext.commands, rc) | ||
| 678 | - | if _, exists := m.commandIndex[rc.Name]; !exists { | 693 | + | _, shadowed := m.commandIndex[rc.Name] |
| 694 | + | if !shadowed { | |||
| 679 | m.commandIndex[rc.Name] = ext | 695 | m.commandIndex[rc.Name] = ext | ||
| 680 | } | 696 | } | ||
| 681 | m.mu.Unlock() | 697 | m.mu.Unlock() | ||
| 698 | + | if shadowed { | |||
| 699 | + | ext.recordDiagnostic(fmt.Sprintf("command /%s conflicts with another extension registering the same name", rc.Name)) | |||
| 700 | + | } | |||
| 682 | } | 701 | } | ||
| 683 | case "register_tool": | 702 | case "register_tool": | ||
| 684 | var rt extproto.RegisterToolFromExt | 703 | var rt extproto.RegisterToolFromExt | ||
| @@ -691,16 +710,21 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 691 | if len(rt.Schema) > 0 { | 710 | if len(rt.Schema) > 0 { | ||
| 692 | var tmp any | 711 | var tmp any | ||
| 693 | if err := json.Unmarshal(rt.Schema, &tmp); err != nil { | 712 | if err := json.Unmarshal(rt.Schema, &tmp); err != nil { | ||
| 713 | + | ext.recordDiagnostic(fmt.Sprintf("tool %q schema is not valid json: %v", rt.Name, err)) | |||
| 694 | fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err) | 714 | fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err) | ||
| 695 | continue | 715 | continue | ||
| 696 | } | 716 | } | ||
| 697 | } | 717 | } | ||
| 698 | m.mu.Lock() | 718 | m.mu.Lock() | ||
| 699 | ext.tools = append(ext.tools, rt) | 719 | ext.tools = append(ext.tools, rt) | ||
| 700 | - | if _, exists := m.toolIndex[rt.Name]; !exists { | 720 | + | _, shadowed := m.toolIndex[rt.Name] |
| 721 | + | if !shadowed { | |||
| 701 | m.toolIndex[rt.Name] = ext | 722 | m.toolIndex[rt.Name] = ext | ||
| 702 | } | 723 | } | ||
| 703 | m.mu.Unlock() | 724 | m.mu.Unlock() | ||
| 725 | + | if shadowed { | |||
| 726 | + | ext.recordDiagnostic(fmt.Sprintf("tool %s conflicts with another extension registering the same name", rt.Name)) | |||
| 727 | + | } | |||
| 704 | case "ready": | 728 | case "ready": | ||
| 705 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | 729 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | ||
| 706 | case "subscribe": | 730 | case "subscribe": | ||
| @@ -805,11 +829,102 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 805 | case "shutdown_ack": | 829 | case "shutdown_ack": | ||
| 806 | // Caller of Stop is waiting on the process exit, not this frame. | 830 | // Caller of Stop is waiting on the process exit, not this frame. | ||
| 807 | default: | 831 | default: | ||
| 832 | + | ext.recordDiagnostic(fmt.Sprintf("unknown frame type %q", frame.Type)) | |||
| 808 | fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type) | 833 | fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type) | ||
| 809 | } | 834 | } | ||
| 810 | } | 835 | } | ||
| 811 | } | 836 | } | ||
| 812 | 837 | ||||
| 838 | + | func (ext *Extension) recordDiagnostic(msg string) { | |||
| 839 | + | ext.mu.Lock() | |||
| 840 | + | defer ext.mu.Unlock() | |||
| 841 | + | ext.diagnostics = append(ext.diagnostics, msg) | |||
| 842 | + | } | |||
| 843 | + | ||||
| 844 | + | // RegisteredCommandDiagnostic describes one command an extension attempted | |||
| 845 | + | // to register and whether it owns the active dispatch slot. | |||
| 846 | + | type RegisteredCommandDiagnostic struct { | |||
| 847 | + | Name string | |||
| 848 | + | Description string | |||
| 849 | + | Active bool | |||
| 850 | + | } | |||
| 851 | + | ||||
| 852 | + | // RegisteredToolDiagnostic describes one tool an extension attempted to | |||
| 853 | + | // register and whether it owns the active dispatch slot. | |||
| 854 | + | type RegisteredToolDiagnostic struct { | |||
| 855 | + | Name string | |||
| 856 | + | Description string | |||
| 857 | + | Active bool | |||
| 858 | + | } | |||
| 859 | + | ||||
| 860 | + | // ExtensionDiagnostic is a stable snapshot for diagnostic commands. | |||
| 861 | + | type ExtensionDiagnostic struct { | |||
| 862 | + | Name string | |||
| 863 | + | Version string | |||
| 864 | + | Dir string | |||
| 865 | + | LogPath string | |||
| 866 | + | ThemeOnly bool | |||
| 867 | + | Ready bool | |||
| 868 | + | AutoReady bool | |||
| 869 | + | ReadyTimedOut bool | |||
| 870 | + | Commands []RegisteredCommandDiagnostic | |||
| 871 | + | Tools []RegisteredToolDiagnostic | |||
| 872 | + | Messages []string | |||
| 873 | + | } | |||
| 874 | + | ||||
| 875 | + | // Diagnostics returns a snapshot of loaded extension state for commands such | |||
| 876 | + | // as `zot ext doctor`. It is read-only and does not change manager behavior. | |||
| 877 | + | func (m *Manager) Diagnostics() []ExtensionDiagnostic { | |||
| 878 | + | m.mu.RLock() | |||
| 879 | + | defer m.mu.RUnlock() | |||
| 880 | + | names := make([]string, 0, len(m.ext)) | |||
| 881 | + | for name := range m.ext { | |||
| 882 | + | names = append(names, name) | |||
| 883 | + | } | |||
| 884 | + | sort.Strings(names) | |||
| 885 | + | out := make([]ExtensionDiagnostic, 0, len(names)) | |||
| 886 | + | for _, name := range names { | |||
| 887 | + | ext := m.ext[name] | |||
| 888 | + | ext.mu.Lock() | |||
| 889 | + | msgs := append([]string(nil), ext.diagnostics...) | |||
| 890 | + | autoReady := ext.autoReady | |||
| 891 | + | readyTimedOut := ext.readyTimedOut | |||
| 892 | + | ext.mu.Unlock() | |||
| 893 | + | ||||
| 894 | + | diag := ExtensionDiagnostic{ | |||
| 895 | + | Name: ext.Manifest.Name, | |||
| 896 | + | Version: ext.Manifest.Version, | |||
| 897 | + | Dir: ext.Dir, | |||
| 898 | + | LogPath: ext.LogPath, | |||
| 899 | + | ThemeOnly: ext.Manifest.Exec == "", | |||
| 900 | + | AutoReady: autoReady, | |||
| 901 | + | ReadyTimedOut: readyTimedOut, | |||
| 902 | + | Messages: msgs, | |||
| 903 | + | } | |||
| 904 | + | select { | |||
| 905 | + | case <-ext.readyCh: | |||
| 906 | + | diag.Ready = true | |||
| 907 | + | default: | |||
| 908 | + | } | |||
| 909 | + | for _, c := range ext.commands { | |||
| 910 | + | diag.Commands = append(diag.Commands, RegisteredCommandDiagnostic{ | |||
| 911 | + | Name: c.Name, | |||
| 912 | + | Description: c.Description, | |||
| 913 | + | Active: m.commandIndex[c.Name] == ext, | |||
| 914 | + | }) | |||
| 915 | + | } | |||
| 916 | + | for _, t := range ext.tools { | |||
| 917 | + | diag.Tools = append(diag.Tools, RegisteredToolDiagnostic{ | |||
| 918 | + | Name: t.Name, | |||
| 919 | + | Description: t.Description, | |||
| 920 | + | Active: m.toolIndex[t.Name] == ext, | |||
| 921 | + | }) | |||
| 922 | + | } | |||
| 923 | + | out = append(out, diag) | |||
| 924 | + | } | |||
| 925 | + | return out | |||
| 926 | + | } | |||
| 927 | + | ||||
| 813 | // Commands returns a snapshot of every (extension, command) pair | 928 | // Commands returns a snapshot of every (extension, command) pair | ||
| 814 | // currently registered. Used by the slash autocomplete + /help. | 929 | // currently registered. Used by the slash autocomplete + /help. | ||
| 815 | func (m *Manager) Commands() []CommandInfo { | 930 | func (m *Manager) Commands() []CommandInfo { | ||
M packages/agent/extensions/manager_test.go +82 -0
| @@ -182,6 +182,88 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||
| 182 | 182 | } | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | + | func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) { | |
| 186 | + | if runtime.GOOS == "windows" { | |
| 187 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | tmp := t.TempDir() | |
| 191 | + | extRoot := filepath.Join(tmp, "extensions") | |
| 192 | + | writeDiagExtension := func(name, script string) { | |
| 193 | + | t.Helper() | |
| 194 | + | dir := filepath.Join(extRoot, name) | |
| 195 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 196 | + | t.Fatal(err) | |
| 197 | + | } | |
| 198 | + | if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil { | |
| 199 | + | t.Fatal(err) | |
| 200 | + | } | |
| 201 | + | mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"}) | |
| 202 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil { | |
| 203 | + | t.Fatal(err) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | writeDiagExtension("a-first", `#!/bin/sh | |
| 208 | + | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}' | |
| 209 | + | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | |
| 210 | + | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | |
| 211 | + | printf '%s\n' '{"type":"ready"}' | |
| 212 | + | while IFS= read -r line; do | |
| 213 | + | case "$line" in | |
| 214 | + | *'"type":"shutdown"'*) exit 0 ;; | |
| 215 | + | esac | |
| 216 | + | done | |
| 217 | + | `) | |
| 218 | + | writeDiagExtension("b-second", `#!/bin/sh | |
| 219 | + | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}' | |
| 220 | + | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | |
| 221 | + | printf '%s\n' '{"type":"ready"}' | |
| 222 | + | while IFS= read -r line; do | |
| 223 | + | case "$line" in | |
| 224 | + | *'"type":"shutdown"'*) exit 0 ;; | |
| 225 | + | esac | |
| 226 | + | done | |
| 227 | + | `) | |
| 228 | + | ||
| 229 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{}) | |
| 230 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |
| 231 | + | t.Fatalf("discover errors: %v", errs) | |
| 232 | + | } | |
| 233 | + | defer mgr.Stop(2 * time.Second) | |
| 234 | + | mgr.WaitForReady(2 * time.Second) | |
| 235 | + | ||
| 236 | + | diags := mgr.Diagnostics() | |
| 237 | + | byName := map[string]ExtensionDiagnostic{} | |
| 238 | + | for _, d := range diags { | |
| 239 | + | byName[d.Name] = d | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | first := byName["a-first"] | |
| 243 | + | if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") { | |
| 244 | + | t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | var shadowedTool bool | |
| 248 | + | var conflictMessage bool | |
| 249 | + | for _, d := range diags { | |
| 250 | + | if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") { | |
| 251 | + | conflictMessage = true | |
| 252 | + | } | |
| 253 | + | for _, tool := range d.Tools { | |
| 254 | + | if tool.Name == "shared" && !tool.Active { | |
| 255 | + | shadowedTool = true | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | if !shadowedTool { | |
| 260 | + | t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags) | |
| 261 | + | } | |
| 262 | + | if !conflictMessage { | |
| 263 | + | t.Fatalf("expected conflict diagnostic, got %#v", diags) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 185 | 267 | // TestSpontaneousOpenPanel verifies that an extension sending an | |
| 186 | 268 | // open_panel frame outside of any command response causes the manager | |
| 187 | 269 | // to call hooks.OpenPanel with the correct PanelSpec fields. | |
| @@ -182,6 +182,88 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||||
| 182 | } | 182 | } | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | + | func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) { | |||
| 186 | + | if runtime.GOOS == "windows" { | |||
| 187 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | tmp := t.TempDir() | |||
| 191 | + | extRoot := filepath.Join(tmp, "extensions") | |||
| 192 | + | writeDiagExtension := func(name, script string) { | |||
| 193 | + | t.Helper() | |||
| 194 | + | dir := filepath.Join(extRoot, name) | |||
| 195 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 196 | + | t.Fatal(err) | |||
| 197 | + | } | |||
| 198 | + | if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil { | |||
| 199 | + | t.Fatal(err) | |||
| 200 | + | } | |||
| 201 | + | mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"}) | |||
| 202 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil { | |||
| 203 | + | t.Fatal(err) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | writeDiagExtension("a-first", `#!/bin/sh | |||
| 208 | + | printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}' | |||
| 209 | + | printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}' | |||
| 210 | + | printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":' | |||
| 211 | + | printf '%s\n' '{"type":"ready"}' | |||
| 212 | + | while IFS= read -r line; do | |||
| 213 | + | case "$line" in | |||
| 214 | + | *'"type":"shutdown"'*) exit 0 ;; | |||
| 215 | + | esac | |||
| 216 | + | done | |||
| 217 | + | `) | |||
| 218 | + | writeDiagExtension("b-second", `#!/bin/sh | |||
| 219 | + | printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}' | |||
| 220 | + | printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}' | |||
| 221 | + | printf '%s\n' '{"type":"ready"}' | |||
| 222 | + | while IFS= read -r line; do | |||
| 223 | + | case "$line" in | |||
| 224 | + | *'"type":"shutdown"'*) exit 0 ;; | |||
| 225 | + | esac | |||
| 226 | + | done | |||
| 227 | + | `) | |||
| 228 | + | ||||
| 229 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{}) | |||
| 230 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |||
| 231 | + | t.Fatalf("discover errors: %v", errs) | |||
| 232 | + | } | |||
| 233 | + | defer mgr.Stop(2 * time.Second) | |||
| 234 | + | mgr.WaitForReady(2 * time.Second) | |||
| 235 | + | ||||
| 236 | + | diags := mgr.Diagnostics() | |||
| 237 | + | byName := map[string]ExtensionDiagnostic{} | |||
| 238 | + | for _, d := range diags { | |||
| 239 | + | byName[d.Name] = d | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | first := byName["a-first"] | |||
| 243 | + | if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") { | |||
| 244 | + | t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | var shadowedTool bool | |||
| 248 | + | var conflictMessage bool | |||
| 249 | + | for _, d := range diags { | |||
| 250 | + | if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") { | |||
| 251 | + | conflictMessage = true | |||
| 252 | + | } | |||
| 253 | + | for _, tool := range d.Tools { | |||
| 254 | + | if tool.Name == "shared" && !tool.Active { | |||
| 255 | + | shadowedTool = true | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | if !shadowedTool { | |||
| 260 | + | t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags) | |||
| 261 | + | } | |||
| 262 | + | if !conflictMessage { | |||
| 263 | + | t.Fatalf("expected conflict diagnostic, got %#v", diags) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 185 | // TestSpontaneousOpenPanel verifies that an extension sending an | 267 | // TestSpontaneousOpenPanel verifies that an extension sending an | ||
| 186 | // open_panel frame outside of any command response causes the manager | 268 | // open_panel frame outside of any command response causes the manager | ||
| 187 | // to call hooks.OpenPanel with the correct PanelSpec fields. | 269 | // to call hooks.OpenPanel with the correct PanelSpec fields. | ||