From 0e681bc52ed07119379375c957aa2de339b65099 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:05:40 +0000 Subject: [PATCH 01/19] feat(config): add server configuration types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ServerConfig struct with port and password_hash fields for web server configuration. The server config is optional and validates port range. - Add ServerConfig struct to types.go - Add Server field to Config struct (optional, pointer) - Add DefaultServerPort constant (8374) - Add DefaultServerConfig() helper function - Add ValidateServerConfig() validation function - Add comprehensive tests for loading and validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/config/loader.go | 24 +++++ internal/config/loader_test.go | 192 +++++++++++++++++++++++++++++++++ internal/config/types.go | 9 +- internal/config/types_test.go | 116 ++++++++++++++++++++ 4 files changed, 340 insertions(+), 1 deletion(-) diff --git a/internal/config/loader.go b/internal/config/loader.go index e061418..591e166 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -18,6 +18,7 @@ const ( DefaultMaxBudgetUSD = 20.0 DefaultMaxDurationHours = 4.0 DefaultNoProgressThreshold = 3 + DefaultServerPort = 8374 ) // DefaultLimits returns limits with sensible default values. @@ -30,6 +31,13 @@ func DefaultLimits() Limits { } } +// DefaultServerConfig returns a ServerConfig with sensible default values. +func DefaultServerConfig() *ServerConfig { + return &ServerConfig{ + Port: DefaultServerPort, + } +} + // DefaultConfig returns a Config with sensible default values. func DefaultConfig() Config { return Config{ @@ -88,6 +96,22 @@ func ValidateConfig(cfg *Config) error { if cfg.Limits.NoProgressThreshold <= 0 { return ValidationError{Field: "limits.no_progress_threshold", Message: "must be positive"} } + + // Validate server config if present + if cfg.Server != nil { + if err := ValidateServerConfig(cfg.Server); err != nil { + return err + } + } + + return nil +} + +// ValidateServerConfig checks that server config values are valid. +func ValidateServerConfig(cfg *ServerConfig) error { + if cfg.Port < 0 || cfg.Port > 65535 { + return ValidationError{Field: "server.port", Message: "must be between 0 and 65535"} + } return nil } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 8223512..b7ab6d3 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -492,3 +492,195 @@ func TestIsValidationError(t *testing.T) { assert.True(t, IsValidationError(ve)) assert.False(t, IsValidationError(os.ErrNotExist)) } + +func TestLoadConfig_WithServerConfig(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + configContent := `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: 9000 + password_hash: "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash" +` + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(configContent), 0o644)) + + cfg, err := LoadConfig(tmpDir) + require.NoError(t, err) + + require.NotNil(t, cfg.Server) + assert.Equal(t, 9000, cfg.Server.Port) + assert.Equal(t, "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash", cfg.Server.PasswordHash) +} + +func TestLoadConfig_ServerConfigOptional(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + // Config without server section + configContent := `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +` + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(configContent), 0o644)) + + cfg, err := LoadConfig(tmpDir) + require.NoError(t, err) + + // Server should be nil when not configured + assert.Nil(t, cfg.Server) +} + +func TestLoadConfig_ServerConfigPartial(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + // Config with only port set + configContent := `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: 8080 +` + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(configContent), 0o644)) + + cfg, err := LoadConfig(tmpDir) + require.NoError(t, err) + + require.NotNil(t, cfg.Server) + assert.Equal(t, 8080, cfg.Server.Port) + assert.Empty(t, cfg.Server.PasswordHash) +} + +func TestLoadConfig_ServerConfigValidationError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + field string + }{ + { + name: "invalid port negative", + content: `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: -1 +`, + field: "server.port", + }, + { + name: "invalid port too high", + content: `limits: + max_iterations: 50 + max_budget_usd: 20 + max_duration_hours: 4 + no_progress_threshold: 3 +server: + port: 65536 +`, + field: "server.port", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(wispDir, "config.yaml"), []byte(tt.content), 0o644)) + + _, err := LoadConfig(tmpDir) + require.Error(t, err) + assert.True(t, IsValidationError(err)) + + var ve ValidationError + require.ErrorAs(t, err, &ve) + assert.Equal(t, tt.field, ve.Field) + }) + } +} + +func TestDefaultServerConfig(t *testing.T) { + t.Parallel() + + cfg := DefaultServerConfig() + assert.Equal(t, DefaultServerPort, cfg.Port) + assert.Empty(t, cfg.PasswordHash) +} + +func TestValidateServerConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *ServerConfig + wantErr bool + field string + }{ + { + name: "valid default port", + config: &ServerConfig{Port: 8374}, + wantErr: false, + }, + { + name: "valid port 0 (dynamic)", + config: &ServerConfig{Port: 0}, + wantErr: false, + }, + { + name: "valid max port", + config: &ServerConfig{Port: 65535}, + wantErr: false, + }, + { + name: "invalid negative port", + config: &ServerConfig{Port: -1}, + wantErr: true, + field: "server.port", + }, + { + name: "invalid port too high", + config: &ServerConfig{Port: 65536}, + wantErr: true, + field: "server.port", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateServerConfig(tt.config) + if tt.wantErr { + require.Error(t, err) + var ve ValidationError + require.ErrorAs(t, err, &ve) + assert.Equal(t, tt.field, ve.Field) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 6db3877..7a60962 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -13,9 +13,16 @@ type Limits struct { NoProgressThreshold int `yaml:"no_progress_threshold"` } +// ServerConfig defines optional web server configuration. +type ServerConfig struct { + Port int `yaml:"port"` + PasswordHash string `yaml:"password_hash"` +} + // Config represents the .wisp/config.yaml file. type Config struct { - Limits Limits `yaml:"limits"` + Limits Limits `yaml:"limits"` + Server *ServerConfig `yaml:"server,omitempty"` } // Permissions defines Claude Code permission rules. diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 20bef03..914ee71 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -448,6 +448,122 @@ func TestConfig_YAMLRoundTrip(t *testing.T) { assert.Equal(t, config, got) } +func TestConfig_WithServer_YAMLRoundTrip(t *testing.T) { + t.Parallel() + + config := Config{ + Limits: Limits{ + MaxIterations: 50, + MaxBudgetUSD: 20.00, + MaxDurationHours: 4, + NoProgressThreshold: 3, + }, + Server: &ServerConfig{ + Port: 9000, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash", + }, + } + + data, err := yaml.Marshal(config) + require.NoError(t, err) + + var got Config + err = yaml.Unmarshal(data, &got) + require.NoError(t, err) + assert.Equal(t, config, got) +} + +func TestServerConfig_YAMLMarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config ServerConfig + want string + }{ + { + name: "full config", + config: ServerConfig{ + Port: 8374, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash", + }, + want: `port: 8374 +password_hash: $argon2id$v=19$m=65536,t=3,p=4$salt$hash +`, + }, + { + name: "port only", + config: ServerConfig{ + Port: 9000, + }, + want: `port: 9000 +password_hash: "" +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := yaml.Marshal(tt.config) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func TestServerConfig_YAMLUnmarshal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want ServerConfig + wantErr bool + }{ + { + name: "full config", + input: `port: 8374 +password_hash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash" +`, + want: ServerConfig{ + Port: 8374, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$salt$hash", + }, + wantErr: false, + }, + { + name: "port only", + input: `port: 9000 +`, + want: ServerConfig{ + Port: 9000, + }, + wantErr: false, + }, + { + name: "invalid yaml", + input: `port: [`, + want: ServerConfig{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var got ServerConfig + err := yaml.Unmarshal([]byte(tt.input), &got) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + func TestSettings_JSONRoundTrip(t *testing.T) { t.Parallel() From 6c1b4b87baa60ae15e61177dfd2c5262d444f817 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:10:12 +0000 Subject: [PATCH 02/19] feat(cli): add --server, --port, --password flags to start command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements server mode flags for the start command: - --server: enables web server alongside TUI for remote access - --port: overrides default server port (8374) - --password: prompts to set/change the server password Adds password handling with argon2id hashing: - Prompts for password when --server is used and no password is configured - Prompts for new password when --password flag is explicitly set - Stores password hash in .wisp/config.yaml Also adds: - internal/auth package with HashPassword, VerifyPassword, and PromptPassword - SaveConfig function to persist config changes - Tests for password hashing and config saving 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- go.mod | 9 +- go.sum | 10 ++- internal/auth/password.go | 151 +++++++++++++++++++++++++++++++++ internal/auth/password_test.go | 138 ++++++++++++++++++++++++++++++ internal/cli/start.go | 75 ++++++++++++++-- internal/config/loader.go | 21 +++++ internal/config/loader_test.go | 106 +++++++++++++++++++++++ 7 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go diff --git a/go.mod b/go.mod index 80032b5..ce5153c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,10 @@ go 1.24.0 require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7 + golang.org/x/crypto v0.47.0 + golang.org/x/term v0.39.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -14,8 +18,5 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7 // indirect - golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect - golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index dc1d9d9..03e563e 100644 --- a/go.sum +++ b/go.sum @@ -20,10 +20,12 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7 h1:RZLYb8iaESjntBxWXLeKbB5de9T4HKd0pod+KQ5NNdU= github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..b1affb2 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,151 @@ +// Package auth provides password hashing and verification using argon2id. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/crypto/argon2" + "golang.org/x/term" +) + +// Argon2id parameters (recommended for password hashing) +const ( + argonTime = 3 // iterations + argonMemory = 65536 // 64 MB + argonThreads = 4 // parallelism + argonKeyLen = 32 // output length + saltLength = 16 // salt length +) + +// HashPassword creates an argon2id hash of the given password. +// Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$$ +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + saltB64 := base64.RawStdEncoding.EncodeToString(salt) + hashB64 := base64.RawStdEncoding.EncodeToString(hash) + + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, saltB64, hashB64), nil +} + +// VerifyPassword checks if the provided password matches the hash. +// The hash must be in the format: $argon2id$v=19$m=65536,t=3,p=4$$ +func VerifyPassword(password, encodedHash string) (bool, error) { + // Parse the hash + params, salt, hash, err := decodeHash(encodedHash) + if err != nil { + return false, err + } + + // Compute hash with the same parameters + computed := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.threads, params.keyLen) + + // Constant-time comparison + if subtle.ConstantTimeCompare(hash, computed) == 1 { + return true, nil + } + return false, nil +} + +type argonParams struct { + memory uint32 + time uint32 + threads uint8 + keyLen uint32 +} + +// decodeHash parses an encoded argon2id hash string. +func decodeHash(encodedHash string) (*argonParams, []byte, []byte, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return nil, nil, nil, fmt.Errorf("invalid hash format: expected 6 parts, got %d", len(parts)) + } + + if parts[1] != "argon2id" { + return nil, nil, nil, fmt.Errorf("invalid hash algorithm: expected argon2id, got %s", parts[1]) + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return nil, nil, nil, fmt.Errorf("invalid version format: %w", err) + } + if version != argon2.Version { + return nil, nil, nil, fmt.Errorf("unsupported argon2 version: %d", version) + } + + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return nil, nil, nil, fmt.Errorf("invalid params format: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid salt encoding: %w", err) + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid hash encoding: %w", err) + } + + return &argonParams{ + memory: memory, + time: time, + threads: threads, + keyLen: uint32(len(hash)), + }, salt, hash, nil +} + +// ErrEmptyPassword is returned when the user enters an empty password. +var ErrEmptyPassword = errors.New("password cannot be empty") + +// ErrPasswordMismatch is returned when password confirmation doesn't match. +var ErrPasswordMismatch = errors.New("passwords do not match") + +// PromptPassword prompts the user for a password (hidden input). +// Returns the entered password. +func PromptPassword(prompt string) (string, error) { + fmt.Print(prompt) + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() // Add newline after hidden input + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + return string(password), nil +} + +// PromptAndConfirmPassword prompts for a password with confirmation. +// Returns the password if both entries match. +func PromptAndConfirmPassword() (string, error) { + password, err := PromptPassword("Enter password for web server: ") + if err != nil { + return "", err + } + if password == "" { + return "", ErrEmptyPassword + } + + confirm, err := PromptPassword("Confirm password: ") + if err != nil { + return "", err + } + + if password != confirm { + return "", ErrPasswordMismatch + } + + return password, nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..a88e654 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,138 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashPassword(t *testing.T) { + t.Parallel() + + password := "test-password-123" + hash, err := HashPassword(password) + require.NoError(t, err) + + // Check format: $argon2id$v=19$m=65536,t=3,p=4$$ + assert.Contains(t, hash, "$argon2id$") + assert.Contains(t, hash, "v=19") + assert.Contains(t, hash, "m=65536,t=3,p=4") +} + +func TestHashPassword_UniquePerCall(t *testing.T) { + t.Parallel() + + password := "same-password" + hash1, err := HashPassword(password) + require.NoError(t, err) + + hash2, err := HashPassword(password) + require.NoError(t, err) + + // Hashes should be different due to random salt + assert.NotEqual(t, hash1, hash2) +} + +func TestVerifyPassword_Correct(t *testing.T) { + t.Parallel() + + password := "correct-horse-battery-staple" + hash, err := HashPassword(password) + require.NoError(t, err) + + match, err := VerifyPassword(password, hash) + require.NoError(t, err) + assert.True(t, match) +} + +func TestVerifyPassword_Incorrect(t *testing.T) { + t.Parallel() + + password := "correct-password" + wrongPassword := "wrong-password" + hash, err := HashPassword(password) + require.NoError(t, err) + + match, err := VerifyPassword(wrongPassword, hash) + require.NoError(t, err) + assert.False(t, match) +} + +func TestVerifyPassword_EmptyPassword(t *testing.T) { + t.Parallel() + + password := "" + hash, err := HashPassword(password) + require.NoError(t, err) + + // Empty password should still verify correctly + match, err := VerifyPassword("", hash) + require.NoError(t, err) + assert.True(t, match) + + // Non-empty should not match + match, err = VerifyPassword("not-empty", hash) + require.NoError(t, err) + assert.False(t, match) +} + +func TestVerifyPassword_InvalidHashFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hash string + }{ + {"empty", ""}, + {"not enough parts", "$argon2id$v=19"}, + {"wrong algorithm", "$bcrypt$v=19$m=65536,t=3,p=4$c2FsdA$aGFzaA"}, + {"invalid version format", "$argon2id$version=19$m=65536,t=3,p=4$c2FsdA$aGFzaA"}, + {"invalid params format", "$argon2id$v=19$memory=65536$c2FsdA$aGFzaA"}, + {"invalid salt encoding", "$argon2id$v=19$m=65536,t=3,p=4$!!!invalid!!!$aGFzaA"}, + {"invalid hash encoding", "$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$!!!invalid!!!"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := VerifyPassword("password", tt.hash) + assert.Error(t, err) + }) + } +} + +func TestVerifyPassword_DifferentParams(t *testing.T) { + t.Parallel() + + // Test that we can verify a hash with different parameters + // This uses the format that our HashPassword produces + password := "test123" + hash, err := HashPassword(password) + require.NoError(t, err) + + // Should still verify correctly + match, err := VerifyPassword(password, hash) + require.NoError(t, err) + assert.True(t, match) +} + +func TestDecodeHash_ValidFormats(t *testing.T) { + t.Parallel() + + // Valid hash from HashPassword + password := "test" + hash, err := HashPassword(password) + require.NoError(t, err) + + params, salt, hashBytes, err := decodeHash(hash) + require.NoError(t, err) + + assert.Equal(t, uint32(65536), params.memory) + assert.Equal(t, uint32(3), params.time) + assert.Equal(t, uint8(4), params.threads) + assert.Equal(t, uint32(32), params.keyLen) + assert.Len(t, salt, 16) + assert.Len(t, hashBytes, 32) +} diff --git a/internal/cli/start.go b/internal/cli/start.go index c3740ef..d2d8b35 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -11,6 +11,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/loop" "github.com/thruflo/wisp/internal/sprite" @@ -19,14 +20,16 @@ import ( ) var ( - startRepo string - startSpec string - startSiblingRepo []string - startBranch string - startTemplate string - startCheckpoint string - startHeadless bool - startContinue bool + startRepo string + startSpec string + startSiblingRepo []string + startBranch string + startTemplate string + startCheckpoint string + startHeadless bool + startServer bool + startServerPort int + startSetPassword bool ) // HeadlessResult is the JSON output format for headless mode. @@ -66,6 +69,9 @@ func init() { startCmd.Flags().StringVarP(&startCheckpoint, "checkpoint", "c", "", "checkpoint ID to restore from") startCmd.Flags().BoolVar(&startHeadless, "headless", false, "run without TUI, print JSON result to stdout (for testing/CI)") startCmd.Flags().BoolVar(&startContinue, "continue", false, "continue on existing branch instead of creating new") + startCmd.Flags().BoolVar(&startServer, "server", false, "start web server alongside TUI for remote access") + startCmd.Flags().IntVar(&startServerPort, "port", config.DefaultServerPort, "web server port (requires --server)") + startCmd.Flags().BoolVar(&startSetPassword, "password", false, "prompt to set/change web server password") startCmd.MarkFlagRequired("repo") startCmd.MarkFlagRequired("spec") @@ -101,6 +107,13 @@ func runStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } + // Handle server mode and password setup + if startServer || startSetPassword { + if err := handleServerPassword(cwd, cfg, startServer, startSetPassword, startServerPort); err != nil { + return err + } + } + // Load settings settings, err := config.LoadSettings(cwd) if err != nil { @@ -676,3 +689,49 @@ func RunCreateTasksPrompt(ctx context.Context, client sprite.Client, session *co return sprite.RunTasksPrompt(ctx, client, session.SpriteName, repoPath, createTasksPath, "RFC path: "+RemoteSpecPath, contextPath, 50) } + +// handleServerPassword handles password setup for the web server. +// It prompts for a password if needed and saves the hash to config. +func handleServerPassword(basePath string, cfg *config.Config, serverEnabled, setPassword bool, port int) error { + // Initialize server config if not present + if cfg.Server == nil { + cfg.Server = config.DefaultServerConfig() + } + + // Update port from flag + cfg.Server.Port = port + + // Check if we need to prompt for password + needsPassword := false + + if setPassword { + // User explicitly wants to set/change password + needsPassword = true + } else if serverEnabled && cfg.Server.PasswordHash == "" { + // Server mode enabled but no password configured + needsPassword = true + } + + if needsPassword { + password, err := auth.PromptAndConfirmPassword() + if err != nil { + return fmt.Errorf("password setup failed: %w", err) + } + + hash, err := auth.HashPassword(password) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + cfg.Server.PasswordHash = hash + + // Save the updated config + if err := config.SaveConfig(basePath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("Password saved to config.") + } + + return nil +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 591e166..ad736f0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -243,3 +243,24 @@ func IsValidationError(err error) bool { var ve ValidationError return errors.As(err, &ve) } + +// SaveConfig writes the config to .wisp/config.yaml at the given base path. +// Creates the .wisp directory if it doesn't exist. +func SaveConfig(basePath string, cfg *Config) error { + wispDir := filepath.Join(basePath, ".wisp") + if err := os.MkdirAll(wispDir, 0o755); err != nil { + return fmt.Errorf("failed to create .wisp directory: %w", err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + configPath := filepath.Join(wispDir, "config.yaml") + if err := os.WriteFile(configPath, data, 0o644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index b7ab6d3..46faa27 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -684,3 +684,109 @@ func TestValidateServerConfig(t *testing.T) { }) } } + +func TestSaveConfig(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + cfg := &Config{ + Limits: Limits{ + MaxIterations: 100, + MaxBudgetUSD: 50.0, + MaxDurationHours: 8.0, + NoProgressThreshold: 5, + }, + } + + err := SaveConfig(tmpDir, cfg) + require.NoError(t, err) + + // Load it back + loaded, err := LoadConfig(tmpDir) + require.NoError(t, err) + + assert.Equal(t, cfg.Limits.MaxIterations, loaded.Limits.MaxIterations) + assert.Equal(t, cfg.Limits.MaxBudgetUSD, loaded.Limits.MaxBudgetUSD) +} + +func TestSaveConfig_WithServer(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + cfg := &Config{ + Limits: Limits{ + MaxIterations: 50, + MaxBudgetUSD: 20.0, + MaxDurationHours: 4.0, + NoProgressThreshold: 3, + }, + Server: &ServerConfig{ + Port: 9000, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash", + }, + } + + err := SaveConfig(tmpDir, cfg) + require.NoError(t, err) + + // Load it back + loaded, err := LoadConfig(tmpDir) + require.NoError(t, err) + + require.NotNil(t, loaded.Server) + assert.Equal(t, 9000, loaded.Server.Port) + assert.Equal(t, "$argon2id$v=19$m=65536,t=3,p=4$testsalt$testhash", loaded.Server.PasswordHash) +} + +func TestSaveConfig_CreatesWispDir(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + cfg := DefaultConfig() + err := SaveConfig(tmpDir, &cfg) + require.NoError(t, err) + + // Verify .wisp directory was created + wispDir := filepath.Join(tmpDir, ".wisp") + info, err := os.Stat(wispDir) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestSaveConfig_OverwritesExisting(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Save initial config + cfg1 := &Config{ + Limits: Limits{ + MaxIterations: 10, + MaxBudgetUSD: 5.0, + MaxDurationHours: 1.0, + NoProgressThreshold: 1, + }, + } + err := SaveConfig(tmpDir, cfg1) + require.NoError(t, err) + + // Save updated config + cfg2 := &Config{ + Limits: Limits{ + MaxIterations: 200, + MaxBudgetUSD: 100.0, + MaxDurationHours: 24.0, + NoProgressThreshold: 10, + }, + } + err = SaveConfig(tmpDir, cfg2) + require.NoError(t, err) + + // Load and verify it's the second config + loaded, err := LoadConfig(tmpDir) + require.NoError(t, err) + assert.Equal(t, 200, loaded.Limits.MaxIterations) +} From ba685dab7d974d37646c323faf70059d0466a157 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:13:47 +0000 Subject: [PATCH 03/19] feat(cli): add --server, --port, --password flags to resume command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the same server-related flags to the resume command that are available on the start command, enabling remote web access when resuming sessions. - Add --server flag to enable web server alongside TUI - Add --port flag to configure server port (default: 8374) - Add --password flag to prompt for password change - Add handleResumeServerPassword function for password handling - Add comprehensive tests for flag registration and password logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cli/resume.go | 64 ++++++++++++++++++++++++++ internal/cli/resume_test.go | 92 +++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/internal/cli/resume.go b/internal/cli/resume.go index 1cfb236..a4a89e5 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/loop" "github.com/thruflo/wisp/internal/sprite" @@ -15,6 +16,12 @@ import ( "github.com/thruflo/wisp/internal/tui" ) +var ( + resumeServer bool + resumeServerPort int + resumeSetPassword bool +) + var resumeCmd = &cobra.Command{ Use: "resume ", Short: "Resume an existing wisp session", @@ -31,6 +38,10 @@ Example: } func init() { + resumeCmd.Flags().BoolVar(&resumeServer, "server", false, "start web server alongside TUI for remote access") + resumeCmd.Flags().IntVar(&resumeServerPort, "port", config.DefaultServerPort, "web server port (requires --server)") + resumeCmd.Flags().BoolVar(&resumeSetPassword, "password", false, "prompt to set/change web server password") + rootCmd.AddCommand(resumeCmd) } @@ -62,6 +73,13 @@ func runResume(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } + // Handle server mode and password setup + if resumeServer || resumeSetPassword { + if err := handleResumeServerPassword(cwd, cfg, resumeServer, resumeSetPassword, resumeServerPort); err != nil { + return err + } + } + // Load settings settings, err := config.LoadSettings(cwd) if err != nil { @@ -356,3 +374,49 @@ func checkoutBranch(ctx context.Context, client sprite.Client, spriteName, repoP return nil } + +// handleResumeServerPassword handles password setup for the web server on resume. +// It prompts for a password if needed and saves the hash to config. +func handleResumeServerPassword(basePath string, cfg *config.Config, serverEnabled, setPassword bool, port int) error { + // Initialize server config if not present + if cfg.Server == nil { + cfg.Server = config.DefaultServerConfig() + } + + // Update port from flag + cfg.Server.Port = port + + // Check if we need to prompt for password + needsPassword := false + + if setPassword { + // User explicitly wants to set/change password + needsPassword = true + } else if serverEnabled && cfg.Server.PasswordHash == "" { + // Server mode enabled but no password configured + needsPassword = true + } + + if needsPassword { + password, err := auth.PromptAndConfirmPassword() + if err != nil { + return fmt.Errorf("password setup failed: %w", err) + } + + hash, err := auth.HashPassword(password) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + cfg.Server.PasswordHash = hash + + // Save the updated config + if err := config.SaveConfig(basePath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("Password saved to config.") + } + + return nil +} diff --git a/internal/cli/resume_test.go b/internal/cli/resume_test.go index 8624cd2..f5fb617 100644 --- a/internal/cli/resume_test.go +++ b/internal/cli/resume_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/state" ) @@ -281,3 +282,94 @@ func TestResumeCommand_ContextCancellation(t *testing.T) { // Verify context is cancelled assert.Error(t, ctx.Err()) } + +func TestResumeServerFlagsRegistered(t *testing.T) { + // Verify the --server flag is registered on the resume command + serverFlag := resumeCmd.Flags().Lookup("server") + require.NotNil(t, serverFlag, "--server flag should be registered") + assert.Equal(t, "bool", serverFlag.Value.Type()) + assert.Equal(t, "false", serverFlag.DefValue) + assert.Contains(t, serverFlag.Usage, "web server") + + // Verify the --port flag is registered on the resume command + portFlag := resumeCmd.Flags().Lookup("port") + require.NotNil(t, portFlag, "--port flag should be registered") + assert.Equal(t, "int", portFlag.Value.Type()) + assert.Equal(t, "8374", portFlag.DefValue) + assert.Contains(t, portFlag.Usage, "port") + + // Verify the --password flag is registered on the resume command + passwordFlag := resumeCmd.Flags().Lookup("password") + require.NotNil(t, passwordFlag, "--password flag should be registered") + assert.Equal(t, "bool", passwordFlag.Value.Type()) + assert.Equal(t, "false", passwordFlag.DefValue) + assert.Contains(t, passwordFlag.Usage, "password") +} + +func TestHandleResumeServerPassword_NoServerConfig(t *testing.T) { + // Test that handleResumeServerPassword creates server config if missing + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + cfg := &config.Config{ + Limits: config.DefaultLimits(), + } + + // Not enabling server, not setting password - should be no-op + err := handleResumeServerPassword(tmpDir, cfg, false, false, 9000) + require.NoError(t, err) + // Server config should still be initialized since function was called + assert.NotNil(t, cfg.Server) + assert.Equal(t, 9000, cfg.Server.Port) + assert.Empty(t, cfg.Server.PasswordHash) +} + +func TestHandleResumeServerPassword_WithExistingPassword(t *testing.T) { + // Test that handleResumeServerPassword doesn't prompt when password exists + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + // Create config with existing password hash + existingHash, err := auth.HashPassword("existingpassword") + require.NoError(t, err) + + cfg := &config.Config{ + Limits: config.DefaultLimits(), + Server: &config.ServerConfig{ + Port: 8374, + PasswordHash: existingHash, + }, + } + + // Server enabled but password already set - should not prompt (no error) + err = handleResumeServerPassword(tmpDir, cfg, true, false, 8080) + require.NoError(t, err) + // Port should be updated, but password hash should be unchanged + assert.Equal(t, 8080, cfg.Server.Port) + assert.Equal(t, existingHash, cfg.Server.PasswordHash) +} + +func TestHandleResumeServerPassword_PortUpdated(t *testing.T) { + // Test that port is updated even when no password change needed + tmpDir := t.TempDir() + wispDir := filepath.Join(tmpDir, ".wisp") + require.NoError(t, os.MkdirAll(wispDir, 0o755)) + + existingHash, err := auth.HashPassword("testpassword") + require.NoError(t, err) + + cfg := &config.Config{ + Limits: config.DefaultLimits(), + Server: &config.ServerConfig{ + Port: 8374, + PasswordHash: existingHash, + }, + } + + // Enable server with different port, password already set + err = handleResumeServerPassword(tmpDir, cfg, true, false, 9999) + require.NoError(t, err) + assert.Equal(t, 9999, cfg.Server.Port) +} From bb384b533f37f5c61d43ad847d96fcdcacd73c2a Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:16:34 +0000 Subject: [PATCH 04/19] feat(server): create internal/server package with core server struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the foundational server package for remote wisp access: - Server struct with Start(ctx) and Stop() methods for lifecycle management - Password verification using argon2 (delegated to auth package) - Token generation using crypto/rand (256-bit random tokens) - Token validation and revocation with expiry-based cleanup - POST /auth endpoint for password authentication returning session token - Comprehensive unit tests for all auth flow components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/server/doc.go | 21 ++ internal/server/server.go | 273 ++++++++++++++++++++ internal/server/server_test.go | 447 +++++++++++++++++++++++++++++++++ 3 files changed, 741 insertions(+) create mode 100644 internal/server/doc.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go diff --git a/internal/server/doc.go b/internal/server/doc.go new file mode 100644 index 0000000..8806a5e --- /dev/null +++ b/internal/server/doc.go @@ -0,0 +1,21 @@ +// Package server provides a web server for remote monitoring and interaction +// with wisp sessions. +// +// The server enables developers to monitor and interact with active wisp sessions +// from any device (phone, tablet, another computer) while away from the machine +// running wisp. It serves a React web client and exposes endpoints for +// authentication, state streaming via Durable Streams, and user input. +// +// # Endpoints +// +// - POST /auth - Password authentication, returns session token +// - GET /stream - Durable Streams endpoint for real-time state updates +// - POST /input - Submit user response to NEEDS_INPUT prompts +// - GET / - Serve embedded web assets (React client) +// +// # Authentication +// +// The server uses password-based authentication with argon2id hashing. +// Clients POST their password to /auth and receive a session token that +// must be included in subsequent requests via the Authorization header. +package server diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..9ca7e86 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,273 @@ +// Package server provides a web server for remote monitoring and interaction +// with wisp sessions. It serves a React web client and exposes endpoints for +// authentication, state streaming via Durable Streams, and user input. +package server + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/thruflo/wisp/internal/auth" + "github.com/thruflo/wisp/internal/config" +) + +// Server represents the web server for remote access to wisp sessions. +type Server struct { + port int + passwordHash string + + // HTTP server + server *http.Server + listener net.Listener + + // Token management + mu sync.RWMutex + tokens map[string]time.Time // token -> expiry time + + // Lifecycle + started bool +} + +// Config holds server configuration options. +type Config struct { + Port int + PasswordHash string +} + +// NewServer creates a new Server instance. +func NewServer(cfg *Config) (*Server, error) { + if cfg == nil { + return nil, errors.New("config is required") + } + if cfg.PasswordHash == "" { + return nil, errors.New("password hash is required") + } + + return &Server{ + port: cfg.Port, + passwordHash: cfg.PasswordHash, + tokens: make(map[string]time.Time), + }, nil +} + +// NewServerFromConfig creates a new Server from a config.ServerConfig. +func NewServerFromConfig(cfg *config.ServerConfig) (*Server, error) { + if cfg == nil { + return nil, errors.New("server config is required") + } + return NewServer(&Config{ + Port: cfg.Port, + PasswordHash: cfg.PasswordHash, + }) +} + +// Port returns the configured port. +func (s *Server) Port() int { + return s.port +} + +// Start starts the HTTP server. +// The server runs until ctx is cancelled or Stop is called. +func (s *Server) Start(ctx context.Context) error { + s.mu.Lock() + if s.started { + s.mu.Unlock() + return errors.New("server already started") + } + + // Create listener + addr := fmt.Sprintf(":%d", s.port) + listener, err := net.Listen("tcp", addr) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + s.listener = listener + + // Setup HTTP server with routes + mux := http.NewServeMux() + s.setupRoutes(mux) + + s.server = &http.Server{ + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + s.started = true + s.mu.Unlock() + + // Start cleanup goroutine for expired tokens + go s.cleanupExpiredTokens(ctx) + + // Run server (blocks until error or server closed) + err = s.server.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("server error: %w", err) + } + + return nil +} + +// Stop gracefully shuts down the server. +func (s *Server) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.started || s.server == nil { + return nil + } + + // Shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := s.server.Shutdown(ctx); err != nil { + return fmt.Errorf("shutdown error: %w", err) + } + + s.started = false + return nil +} + +// ListenAddr returns the actual address the server is listening on. +// Useful when port 0 is used to get an available port. +// Returns empty string if not started. +func (s *Server) ListenAddr() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.listener == nil { + return "" + } + return s.listener.Addr().String() +} + +// setupRoutes configures the HTTP routes. +func (s *Server) setupRoutes(mux *http.ServeMux) { + mux.HandleFunc("/auth", s.handleAuth) + // Additional routes will be added in future tasks: + // mux.HandleFunc("/stream", s.handleStream) + // mux.HandleFunc("/input", s.handleInput) + // mux.HandleFunc("/", s.handleStatic) +} + +// VerifyPassword checks if the provided password matches the stored hash. +func (s *Server) VerifyPassword(password string) (bool, error) { + return auth.VerifyPassword(password, s.passwordHash) +} + +// tokenExpiry is how long tokens are valid. +const tokenExpiry = 24 * time.Hour + +// GenerateToken creates a new authentication token. +func (s *Server) GenerateToken() (string, error) { + // Generate 32 bytes of random data (256 bits) + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate token: %w", err) + } + + token := hex.EncodeToString(bytes) + + // Store token with expiry + s.mu.Lock() + s.tokens[token] = time.Now().Add(tokenExpiry) + s.mu.Unlock() + + return token, nil +} + +// ValidateToken checks if a token is valid and not expired. +func (s *Server) ValidateToken(token string) bool { + if token == "" { + return false + } + + s.mu.RLock() + expiry, exists := s.tokens[token] + s.mu.RUnlock() + + if !exists { + return false + } + + return time.Now().Before(expiry) +} + +// RevokeToken removes a token from the valid tokens map. +func (s *Server) RevokeToken(token string) { + s.mu.Lock() + delete(s.tokens, token) + s.mu.Unlock() +} + +// cleanupExpiredTokens periodically removes expired tokens. +func (s *Server) cleanupExpiredTokens(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.mu.Lock() + now := time.Now() + for token, expiry := range s.tokens { + if now.After(expiry) { + delete(s.tokens, token) + } + } + s.mu.Unlock() + } + } +} + +// handleAuth handles POST /auth for password authentication. +func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse form data + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + password := r.FormValue("password") + if password == "" { + http.Error(w, "password required", http.StatusBadRequest) + return + } + + // Verify password + valid, err := s.VerifyPassword(password) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !valid { + http.Error(w, "invalid password", http.StatusUnauthorized) + return + } + + // Generate token + token, err := s.GenerateToken() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Return token as JSON + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"token":"%s"}`, token) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..aff1f4d --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,447 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/thruflo/wisp/internal/auth" + "github.com/thruflo/wisp/internal/config" +) + +const testPassword = "test-password-123" + +// createTestServer creates a server with a known password hash for testing. +func createTestServer(t *testing.T) *Server { + t.Helper() + + hash, err := auth.HashPassword(testPassword) + if err != nil { + t.Fatalf("failed to hash password: %v", err) + } + + server, err := NewServer(&Config{ + Port: 0, // random available port + PasswordHash: hash, + }) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + + return server +} + +func TestNewServer(t *testing.T) { + tests := []struct { + name string + cfg *Config + wantErr string + }{ + { + name: "nil config", + cfg: nil, + wantErr: "config is required", + }, + { + name: "empty password hash", + cfg: &Config{ + Port: 8080, + PasswordHash: "", + }, + wantErr: "password hash is required", + }, + { + name: "valid config", + cfg: &Config{ + Port: 8080, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$test$hash", + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, err := NewServer(tt.cfg) + if tt.wantErr != "" { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if server == nil { + t.Error("expected server, got nil") + return + } + if server.Port() != tt.cfg.Port { + t.Errorf("expected port %d, got %d", tt.cfg.Port, server.Port()) + } + }) + } +} + +func TestNewServerFromConfig(t *testing.T) { + t.Run("nil config", func(t *testing.T) { + _, err := NewServerFromConfig(nil) + if err == nil { + t.Error("expected error for nil config") + } + }) + + t.Run("valid config", func(t *testing.T) { + cfg := &config.ServerConfig{ + Port: 8374, + PasswordHash: "$argon2id$v=19$m=65536,t=3,p=4$test$hash", + } + server, err := NewServerFromConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if server.Port() != 8374 { + t.Errorf("expected port 8374, got %d", server.Port()) + } + }) +} + +func TestVerifyPassword(t *testing.T) { + server := createTestServer(t) + + t.Run("correct password", func(t *testing.T) { + valid, err := server.VerifyPassword(testPassword) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !valid { + t.Error("expected password to be valid") + } + }) + + t.Run("wrong password", func(t *testing.T) { + valid, err := server.VerifyPassword("wrong-password") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if valid { + t.Error("expected password to be invalid") + } + }) + + t.Run("empty password", func(t *testing.T) { + valid, err := server.VerifyPassword("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if valid { + t.Error("expected empty password to be invalid") + } + }) +} + +func TestGenerateToken(t *testing.T) { + server := createTestServer(t) + + t.Run("generates token", func(t *testing.T) { + token, err := server.GenerateToken() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token == "" { + t.Error("expected non-empty token") + } + // Token should be 64 hex characters (32 bytes) + if len(token) != 64 { + t.Errorf("expected token length 64, got %d", len(token)) + } + }) + + t.Run("tokens are unique", func(t *testing.T) { + token1, _ := server.GenerateToken() + token2, _ := server.GenerateToken() + if token1 == token2 { + t.Error("expected unique tokens") + } + }) + + t.Run("token is valid after generation", func(t *testing.T) { + token, _ := server.GenerateToken() + if !server.ValidateToken(token) { + t.Error("expected generated token to be valid") + } + }) +} + +func TestValidateToken(t *testing.T) { + server := createTestServer(t) + + t.Run("empty token", func(t *testing.T) { + if server.ValidateToken("") { + t.Error("expected empty token to be invalid") + } + }) + + t.Run("non-existent token", func(t *testing.T) { + if server.ValidateToken("non-existent-token") { + t.Error("expected non-existent token to be invalid") + } + }) + + t.Run("valid token", func(t *testing.T) { + token, _ := server.GenerateToken() + if !server.ValidateToken(token) { + t.Error("expected valid token to pass validation") + } + }) + + t.Run("revoked token", func(t *testing.T) { + token, _ := server.GenerateToken() + server.RevokeToken(token) + if server.ValidateToken(token) { + t.Error("expected revoked token to be invalid") + } + }) +} + +func TestRevokeToken(t *testing.T) { + server := createTestServer(t) + + t.Run("revoke existing token", func(t *testing.T) { + token, _ := server.GenerateToken() + server.RevokeToken(token) + if server.ValidateToken(token) { + t.Error("expected revoked token to be invalid") + } + }) + + t.Run("revoke non-existent token", func(t *testing.T) { + // Should not panic + server.RevokeToken("non-existent") + }) +} + +func TestHandleAuth(t *testing.T) { + server := createTestServer(t) + + mux := http.NewServeMux() + server.setupRoutes(mux) + + t.Run("wrong method", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/auth", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } + }) + + t.Run("missing password", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/auth", nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + }) + + t.Run("wrong password", func(t *testing.T) { + form := url.Values{} + form.Add("password", "wrong-password") + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("correct password", func(t *testing.T) { + form := url.Values{} + form.Add("password", testPassword) + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + // Parse response + var response struct { + Token string `json:"token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if response.Token == "" { + t.Error("expected non-empty token in response") + } + + // Verify token is valid + if !server.ValidateToken(response.Token) { + t.Error("expected returned token to be valid") + } + }) +} + +func TestServerStartStop(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start server in goroutine + errCh := make(chan error, 1) + go func() { + errCh <- server.Start(ctx) + }() + + // Give server time to start + time.Sleep(50 * time.Millisecond) + + // Verify server is listening + addr := server.ListenAddr() + if addr == "" { + t.Fatal("expected server to be listening") + } + + // Make a request + resp, err := http.Get("http://" + addr + "/auth") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + resp.Body.Close() + + // Status should be 405 (method not allowed for GET) + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode) + } + + // Stop server + if err := server.Stop(); err != nil { + t.Fatalf("failed to stop server: %v", err) + } + + // Server should have exited cleanly + select { + case err := <-errCh: + if err != nil { + t.Errorf("server returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("server did not exit in time") + } +} + +func TestServerDoubleStart(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start server + errCh := make(chan error, 1) + go func() { + errCh <- server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + + // Try to start again + err := server.Start(ctx) + if err == nil { + t.Error("expected error when starting already-started server") + } + if !strings.Contains(err.Error(), "already started") { + t.Errorf("expected 'already started' error, got: %v", err) + } + + // Cleanup + server.Stop() +} + +func TestServerStopNotStarted(t *testing.T) { + server := createTestServer(t) + + // Stop without starting should not error + if err := server.Stop(); err != nil { + t.Errorf("unexpected error stopping non-started server: %v", err) + } +} + +func TestAuthEndToEnd(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start server + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + // Authenticate with correct password + form := url.Values{} + form.Add("password", testPassword) + resp, err := http.PostForm("http://"+addr+"/auth", form) + if err != nil { + t.Fatalf("failed to authenticate: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + var response struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Token should be valid + if !server.ValidateToken(response.Token) { + t.Error("token should be valid") + } + + // Authenticate with wrong password + form = url.Values{} + form.Add("password", "wrong-password") + resp2, err := http.PostForm("http://"+addr+"/auth", form) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + resp2.Body.Close() + + if resp2.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d for wrong password, got %d", http.StatusUnauthorized, resp2.StatusCode) + } +} From 2487e8c9daf834467010cf3ae65d04139c53d85a Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:22:22 +0000 Subject: [PATCH 05/19] feat(server): implement Durable Streams integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add StreamManager wrapping durable-streams MemoryStore for real-time state streaming to web clients. Includes: - Message types for session, task, claude_event, input_request, and delete - Broadcast methods for each message type with state tracking - Integration with Server struct for lifecycle management - Comprehensive tests for message serialization and stream operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- go.mod | 120 ++++++- go.sum | 520 +++++++++++++++++++++++++++ internal/server/server.go | 19 + internal/server/streams.go | 289 +++++++++++++++ internal/server/streams_test.go | 604 ++++++++++++++++++++++++++++++++ 5 files changed, 1550 insertions(+), 2 deletions(-) create mode 100644 internal/server/streams.go create mode 100644 internal/server/streams_test.go diff --git a/go.mod b/go.mod index ce5153c..a451729 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/thruflo/wisp -go 1.24.0 +go 1.25 require ( github.com/spf13/cobra v1.10.2 @@ -12,11 +12,127 @@ require ( ) require ( - github.com/Masterminds/semver/v3 v3.2.1 // indirect + cel.dev/expr v0.24.0 // indirect + cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect + dario.cat/mergo v1.0.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect + github.com/KimMachineGun/automemlimit v0.7.4 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/caddyserver/caddy/v2 v2.10.2 // indirect + github.com/caddyserver/certmagic v0.24.0 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/ccoveille/go-safecast v1.6.1 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chzyer/readline v1.5.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/coreos/go-oidc/v3 v3.14.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgraph-io/badger v1.6.2 // indirect + github.com/dgraph-io/badger/v2 v2.2007.4 // indirect + github.com/dgraph-io/ristretto v0.2.0 // indirect + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect + github.com/durable-streams/durable-streams/packages/caddy-plugin v0.0.0-20260116005712-42bf4d3b3e45 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/francoispqt/gojay v1.2.13 // indirect + github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/libdns v1.1.0 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mholt/acmez/v3 v3.1.2 // indirect + github.com/miekg/dns v1.1.63 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/slackhq/nebula v1.9.5 // indirect + github.com/smallstep/certificates v0.28.4 // indirect + github.com/smallstep/cli-utils v0.12.1 // indirect + github.com/smallstep/linkedca v0.23.0 // indirect + github.com/smallstep/nosql v0.7.0 // indirect + github.com/smallstep/pkcs7 v0.2.1 // indirect + github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 // indirect + github.com/smallstep/truststore v0.13.0 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 // indirect + github.com/urfave/cli v1.22.17 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.etcd.io/bbolt v1.4.3 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.step.sm/crypto v0.67.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/mock v0.5.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/api v0.240.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect + howett.net/plist v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 03e563e..847af2b 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,552 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= +github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/KimMachineGun/automemlimit v0.7.4 h1:UY7QYOIfrr3wjjOAqahFmC3IaQCLWvur9nmfIn6LnWk= +github.com/KimMachineGun/automemlimit v0.7.4/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= +github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= +github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/caddyserver/caddy/v2 v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8= +github.com/caddyserver/caddy/v2 v2.10.2/go.mod h1:TXLQHx+ev4HDpkO6PnVVHUbL6OXt6Dfe7VcIBdQnPL0= +github.com/caddyserver/certmagic v0.24.0 h1:EfXTWpxHAUKgDfOj6MHImJN8Jm4AMFfMT6ITuKhrDF0= +github.com/caddyserver/certmagic v0.24.0/go.mod h1:xPT7dC1DuHHnS2yuEQCEyks+b89sUkMENh8dJF+InLE= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/ccoveille/go-safecast v1.6.1 h1:Nb9WMDR8PqhnKCVs2sCB+OqhohwO5qaXtCviZkIff5Q= +github.com/ccoveille/go-safecast v1.6.1/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.2 h1:mNw0qs90GVgGGWylh0umH5iag1j6n/PeJtNvL6KY/x8= +github.com/dgraph-io/badger v1.6.2/go.mod h1:JW2yswe3V058sS0kZ2h/AXeDSqFjxnZcRrVH//y2UQE= +github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o= +github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk= +github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= +github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/durable-streams/durable-streams/packages/caddy-plugin v0.0.0-20260116005712-42bf4d3b3e45 h1:mWYXH+vP9jZJ7FJKQpAu7Jj8L/tSM+grkGBW7mA1D34= +github.com/durable-streams/durable-streams/packages/caddy-plugin v0.0.0-20260116005712-42bf4d3b3e45/go.mod h1:mX4HnrJ9vDQhrE5pVp/gvF75wDpmqFzlOQpG3BC8XkA= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +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.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= +github.com/libdns/libdns v1.1.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= +github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +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/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +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.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= +github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/slackhq/nebula v1.9.5 h1:ZrxcvP/lxwFglaijmiwXLuCSkybZMJnqSYI1S8DtGnY= +github.com/slackhq/nebula v1.9.5/go.mod h1:1+4q4wd3dDAjO8rKCttSb9JIVbklQhuJiBp5I0lbIsQ= +github.com/smallstep/certificates v0.28.4 h1:JTU6/A5Xes6m+OsR6fw1RACSA362vJc9SOFVG7poBEw= +github.com/smallstep/certificates v0.28.4/go.mod h1:LUqo+7mKZE7FZldlTb0zhU4A0bq4G4+akieFMcTaWvA= +github.com/smallstep/cli-utils v0.12.1 h1:D9QvfbFqiKq3snGZ2xDcXEFrdFJ1mQfPHZMq/leerpE= +github.com/smallstep/cli-utils v0.12.1/go.mod h1:skV2Neg8qjiKPu2fphM89H9bIxNpKiiRTnX9Q6Lc+20= +github.com/smallstep/linkedca v0.23.0 h1:5W/7EudlK1HcCIdZM68dJlZ7orqCCCyv6bm2l/0JmLU= +github.com/smallstep/linkedca v0.23.0/go.mod h1:7cyRM9soAYySg9ag65QwytcgGOM+4gOlkJ/YA58A9E8= +github.com/smallstep/nosql v0.7.0 h1:YiWC9ZAHcrLCrayfaF+QJUv16I2bZ7KdLC3RpJcnAnE= +github.com/smallstep/nosql v0.7.0/go.mod h1:H5VnKMCbeq9QA6SRY5iqPylfxLfYcLwvUff3onQ8+HU= +github.com/smallstep/pkcs7 v0.0.0-20240911091500-b1cae6277023/go.mod h1:CM5KrX7rxWgwDdMj9yef/pJB2OPgy/56z4IEx2UIbpc= +github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA= +github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= +github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 h1:LyZqn24/ZiVg8v9Hq07K6mx6RqPtpDeK+De5vf4QEY4= +github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101/go.mod h1:EuKQjYGQwhUa1mgD21zxIgOgUYLsqikJmvxNscxpS/Y= +github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= +github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7 h1:RZLYb8iaESjntBxWXLeKbB5de9T4HKd0pod+KQ5NNdU= github.com/superfly/sprites-go v0.0.0-20260115223022-8bc92b9127c7/go.mod h1:4zltGIGJa3HV+XumRyNn4BmhlavbUZH3Uh5xJNaDwsY= +github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 h1:uxMgm0C+EjytfAqyfBG55ZONKQ7mvd7x4YYCWsf8QHQ= +github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= +github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.step.sm/crypto v0.67.0 h1:1km9LmxMKG/p+mKa1R4luPN04vlJYnRLlLQrWv7egGU= +go.step.sm/crypto v0.67.0/go.mod h1:+AoDpB0mZxbW/PmOXuwkPSpXRgaUaoIK+/Wx/HGgtAU= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810 h1:V5+zy0jmgNYmK1uW/sPpBw8ioFvalrhaUrYWmu1Fpe4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250305170421-49bf5b80c810/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190313220215-9f648a60d977/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= +google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/internal/server/server.go b/internal/server/server.go index 9ca7e86..6783644 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -31,6 +31,9 @@ type Server struct { mu sync.RWMutex tokens map[string]time.Time // token -> expiry time + // Durable Streams + streams *StreamManager + // Lifecycle started bool } @@ -50,10 +53,16 @@ func NewServer(cfg *Config) (*Server, error) { return nil, errors.New("password hash is required") } + streams, err := NewStreamManager() + if err != nil { + return nil, fmt.Errorf("failed to create stream manager: %w", err) + } + return &Server{ port: cfg.Port, passwordHash: cfg.PasswordHash, tokens: make(map[string]time.Time), + streams: streams, }, nil } @@ -133,10 +142,20 @@ func (s *Server) Stop() error { return fmt.Errorf("shutdown error: %w", err) } + // Close the stream manager + if s.streams != nil { + s.streams.Close() + } + s.started = false return nil } +// Streams returns the StreamManager for broadcasting state. +func (s *Server) Streams() *StreamManager { + return s.streams +} + // ListenAddr returns the actual address the server is listening on. // Useful when port 0 is used to get an available port. // Returns empty string if not started. diff --git a/internal/server/streams.go b/internal/server/streams.go new file mode 100644 index 0000000..daf93d4 --- /dev/null +++ b/internal/server/streams.go @@ -0,0 +1,289 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/durable-streams/durable-streams/packages/caddy-plugin/store" +) + +const ( + // streamPath is the default path for the wisp event stream. + streamPath = "/wisp/events" + // streamContentType is the content type for the event stream. + streamContentType = "application/json" +) + +// MessageType identifies the type of message in the stream. +type MessageType string + +const ( + // MessageTypeSession is a session state update. + MessageTypeSession MessageType = "session" + // MessageTypeTask is a task state update. + MessageTypeTask MessageType = "task" + // MessageTypeClaudeEvent is a Claude output event. + MessageTypeClaudeEvent MessageType = "claude_event" + // MessageTypeInputRequest is an input request. + MessageTypeInputRequest MessageType = "input_request" + // MessageTypeDelete indicates a deletion. + MessageTypeDelete MessageType = "delete" +) + +// StreamMessage represents a message in the durable stream. +type StreamMessage struct { + Type MessageType `json:"type"` + Data any `json:"data,omitempty"` + // For delete operations: + Collection string `json:"collection,omitempty"` + ID string `json:"id,omitempty"` +} + +// Session represents a wisp session for streaming to clients. +type Session struct { + ID string `json:"id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Spec string `json:"spec"` + Status SessionStatus `json:"status"` + Iteration int `json:"iteration"` + StartedAt string `json:"started_at"` +} + +// SessionStatus represents the status of a session. +type SessionStatus string + +const ( + SessionStatusRunning SessionStatus = "running" + SessionStatusNeedsInput SessionStatus = "needs_input" + SessionStatusBlocked SessionStatus = "blocked" + SessionStatusDone SessionStatus = "done" +) + +// Task represents a task within a session. +type Task struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Order int `json:"order"` + Content string `json:"content"` + Status TaskStatus `json:"status"` +} + +// TaskStatus represents the status of a task. +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusInProgress TaskStatus = "in_progress" + TaskStatusCompleted TaskStatus = "completed" +) + +// ClaudeEvent represents a Claude output event. +// The Message field contains the raw SDK message (assistant, result, system, user). +type ClaudeEvent struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Iteration int `json:"iteration"` + Sequence int `json:"sequence"` + Message any `json:"message"` // Raw SDKMessage from Claude stream-json output + Timestamp string `json:"timestamp"` +} + +// InputRequest represents a request for user input. +type InputRequest struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Iteration int `json:"iteration"` + Question string `json:"question"` + Responded bool `json:"responded"` + Response *string `json:"response"` // nil if not responded +} + +// StreamManager wraps a MemoryStore for managing the event stream. +type StreamManager struct { + store *store.MemoryStore + mu sync.RWMutex + + // Track current state for initial sync + sessions map[string]*Session + tasks map[string]*Task + inputRequests map[string]*InputRequest +} + +// NewStreamManager creates a new StreamManager with an initialized MemoryStore. +func NewStreamManager() (*StreamManager, error) { + memStore := store.NewMemoryStore() + + // Create the stream + _, _, err := memStore.Create(streamPath, store.CreateOptions{ + ContentType: streamContentType, + }) + if err != nil { + return nil, fmt.Errorf("failed to create stream: %w", err) + } + + return &StreamManager{ + store: memStore, + sessions: make(map[string]*Session), + tasks: make(map[string]*Task), + inputRequests: make(map[string]*InputRequest), + }, nil +} + +// Store returns the underlying MemoryStore. +func (sm *StreamManager) Store() *store.MemoryStore { + return sm.store +} + +// StreamPath returns the path for the event stream. +func (sm *StreamManager) StreamPath() string { + return streamPath +} + +// append serializes and appends a message to the stream. +func (sm *StreamManager) append(msg StreamMessage) error { + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + _, err = sm.store.Append(streamPath, data, store.AppendOptions{}) + if err != nil { + return fmt.Errorf("failed to append message: %w", err) + } + + return nil +} + +// BroadcastSession broadcasts a session state update. +func (sm *StreamManager) BroadcastSession(session *Session) error { + if session == nil { + return errors.New("session is nil") + } + + sm.mu.Lock() + sm.sessions[session.ID] = session + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeSession, + Data: session, + }) +} + +// BroadcastTask broadcasts a task state update. +func (sm *StreamManager) BroadcastTask(task *Task) error { + if task == nil { + return errors.New("task is nil") + } + + sm.mu.Lock() + sm.tasks[task.ID] = task + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeTask, + Data: task, + }) +} + +// BroadcastClaudeEvent broadcasts a Claude output event. +func (sm *StreamManager) BroadcastClaudeEvent(event *ClaudeEvent) error { + if event == nil { + return errors.New("event is nil") + } + + // Claude events are not tracked for initial sync (too many) + return sm.append(StreamMessage{ + Type: MessageTypeClaudeEvent, + Data: event, + }) +} + +// BroadcastInputRequest broadcasts an input request. +func (sm *StreamManager) BroadcastInputRequest(req *InputRequest) error { + if req == nil { + return errors.New("input request is nil") + } + + sm.mu.Lock() + sm.inputRequests[req.ID] = req + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeInputRequest, + Data: req, + }) +} + +// BroadcastDelete broadcasts a deletion message. +func (sm *StreamManager) BroadcastDelete(collection, id string) error { + if collection == "" || id == "" { + return errors.New("collection and id are required") + } + + // Remove from tracked state + sm.mu.Lock() + switch collection { + case "sessions": + delete(sm.sessions, id) + case "tasks": + delete(sm.tasks, id) + case "input_requests": + delete(sm.inputRequests, id) + } + sm.mu.Unlock() + + return sm.append(StreamMessage{ + Type: MessageTypeDelete, + Collection: collection, + ID: id, + }) +} + +// GetCurrentOffset returns the current tail offset of the stream. +func (sm *StreamManager) GetCurrentOffset() (store.Offset, error) { + return sm.store.GetCurrentOffset(streamPath) +} + +// Read reads messages from the stream starting at the given offset. +func (sm *StreamManager) Read(offset store.Offset) ([]store.Message, bool, error) { + return sm.store.Read(streamPath, offset) +} + +// WaitForMessages waits for new messages after the given offset. +func (sm *StreamManager) WaitForMessages(ctx context.Context, offset store.Offset, timeout time.Duration) ([]store.Message, bool, error) { + return sm.store.WaitForMessages(ctx, streamPath, offset, timeout) +} + +// GetCurrentState returns all currently tracked state for initial sync. +func (sm *StreamManager) GetCurrentState() (sessions []*Session, tasks []*Task, inputRequests []*InputRequest) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + sessions = make([]*Session, 0, len(sm.sessions)) + for _, s := range sm.sessions { + sessions = append(sessions, s) + } + + tasks = make([]*Task, 0, len(sm.tasks)) + for _, t := range sm.tasks { + tasks = append(tasks, t) + } + + inputRequests = make([]*InputRequest, 0, len(sm.inputRequests)) + for _, r := range sm.inputRequests { + inputRequests = append(inputRequests, r) + } + + return +} + +// Close releases resources held by the StreamManager. +func (sm *StreamManager) Close() error { + return sm.store.Close() +} diff --git a/internal/server/streams_test.go b/internal/server/streams_test.go new file mode 100644 index 0000000..62a8258 --- /dev/null +++ b/internal/server/streams_test.go @@ -0,0 +1,604 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/durable-streams/durable-streams/packages/caddy-plugin/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStreamManager(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + require.NotNil(t, sm) + defer sm.Close() + + // Should have created a store + assert.NotNil(t, sm.Store()) + + // Should have a stream path + assert.Equal(t, "/wisp/events", sm.StreamPath()) +} + +func TestStreamManager_BroadcastSession(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + session := &Session{ + ID: "test-session-1", + Repo: "user/repo", + Branch: "wisp/feature", + Spec: "docs/rfc.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + + err = sm.BroadcastSession(session) + require.NoError(t, err) + + // Read the message back + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + // Verify serialization + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeSession, msg.Type) + + // Verify data can be converted to Session + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var s Session + err = json.Unmarshal(dataBytes, &s) + require.NoError(t, err) + assert.Equal(t, "test-session-1", s.ID) + assert.Equal(t, "user/repo", s.Repo) + assert.Equal(t, SessionStatusRunning, s.Status) +} + +func TestStreamManager_BroadcastSession_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastSession(nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") +} + +func TestStreamManager_BroadcastTask(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + task := &Task{ + ID: "task-1", + SessionID: "test-session-1", + Order: 0, + Content: "Implement feature X", + Status: TaskStatusInProgress, + } + + err = sm.BroadcastTask(task) + require.NoError(t, err) + + // Read the message back + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + // Verify serialization + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeTask, msg.Type) + + // Verify data + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var tsk Task + err = json.Unmarshal(dataBytes, &tsk) + require.NoError(t, err) + assert.Equal(t, "task-1", tsk.ID) + assert.Equal(t, TaskStatusInProgress, tsk.Status) +} + +func TestStreamManager_BroadcastTask_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastTask(nil) + assert.Error(t, err) +} + +func TestStreamManager_BroadcastClaudeEvent(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Use a map to simulate raw SDK message + sdkMessage := map[string]any{ + "type": "assistant", + "message": map[string]any{ + "content": []any{ + map[string]any{ + "type": "text", + "text": "Hello, world!", + }, + }, + }, + } + + event := &ClaudeEvent{ + ID: "session-1-1-42", + SessionID: "session-1", + Iteration: 1, + Sequence: 42, + Message: sdkMessage, + Timestamp: "2024-01-15T10:30:00Z", + } + + err = sm.BroadcastClaudeEvent(event) + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeClaudeEvent, msg.Type) + + // Verify the message field contains the SDK message + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var evt ClaudeEvent + err = json.Unmarshal(dataBytes, &evt) + require.NoError(t, err) + assert.Equal(t, "session-1-1-42", evt.ID) + assert.Equal(t, 42, evt.Sequence) + + // Verify the SDK message is preserved + msgMap, ok := evt.Message.(map[string]any) + require.True(t, ok) + assert.Equal(t, "assistant", msgMap["type"]) +} + +func TestStreamManager_BroadcastClaudeEvent_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastClaudeEvent(nil) + assert.Error(t, err) +} + +func TestStreamManager_BroadcastInputRequest(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + response := "yes, continue" + req := &InputRequest{ + ID: "input-1", + SessionID: "session-1", + Iteration: 2, + Question: "Should we continue?", + Responded: true, + Response: &response, + } + + err = sm.BroadcastInputRequest(req) + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeInputRequest, msg.Type) + + // Verify data + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var ir InputRequest + err = json.Unmarshal(dataBytes, &ir) + require.NoError(t, err) + assert.Equal(t, "input-1", ir.ID) + assert.True(t, ir.Responded) + require.NotNil(t, ir.Response) + assert.Equal(t, "yes, continue", *ir.Response) +} + +func TestStreamManager_BroadcastInputRequest_NotResponded(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + req := &InputRequest{ + ID: "input-2", + SessionID: "session-1", + Iteration: 3, + Question: "What should we do?", + Responded: false, + Response: nil, + } + + err = sm.BroadcastInputRequest(req) + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + + dataBytes, err := json.Marshal(msg.Data) + require.NoError(t, err) + var ir InputRequest + err = json.Unmarshal(dataBytes, &ir) + require.NoError(t, err) + assert.False(t, ir.Responded) + assert.Nil(t, ir.Response) +} + +func TestStreamManager_BroadcastInputRequest_Nil(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastInputRequest(nil) + assert.Error(t, err) +} + +func TestStreamManager_BroadcastDelete(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastDelete("sessions", "session-1") + require.NoError(t, err) + + // Read and verify + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + err = json.Unmarshal(messages[0].Data, &msg) + require.NoError(t, err) + assert.Equal(t, MessageTypeDelete, msg.Type) + assert.Equal(t, "sessions", msg.Collection) + assert.Equal(t, "session-1", msg.ID) +} + +func TestStreamManager_BroadcastDelete_Empty(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + err = sm.BroadcastDelete("", "id") + assert.Error(t, err) + + err = sm.BroadcastDelete("sessions", "") + assert.Error(t, err) +} + +func TestStreamManager_MultipleMessages(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast multiple messages + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + task1 := &Task{ + ID: "task-1", + SessionID: "sess-1", + Order: 0, + Content: "Task one", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task1)) + + task2 := &Task{ + ID: "task-2", + SessionID: "sess-1", + Order: 1, + Content: "Task two", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task2)) + + // Read all messages + messages, _, err := sm.Read(store.ZeroOffset) + require.NoError(t, err) + assert.Len(t, messages, 3) + + // Verify message order and types + var msg0, msg1, msg2 StreamMessage + require.NoError(t, json.Unmarshal(messages[0].Data, &msg0)) + require.NoError(t, json.Unmarshal(messages[1].Data, &msg1)) + require.NoError(t, json.Unmarshal(messages[2].Data, &msg2)) + + assert.Equal(t, MessageTypeSession, msg0.Type) + assert.Equal(t, MessageTypeTask, msg1.Type) + assert.Equal(t, MessageTypeTask, msg2.Type) +} + +func TestStreamManager_GetCurrentOffset(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Get initial offset + offset1, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Broadcast a message + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Get new offset + offset2, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Offset should have increased + assert.True(t, offset2.ByteOffset > offset1.ByteOffset) +} + +func TestStreamManager_ReadFromOffset(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast first message + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Get offset after first message + offset, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Broadcast second message + task := &Task{ + ID: "task-1", + SessionID: "sess-1", + Order: 0, + Content: "Task one", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task)) + + // Read from offset (should only get second message) + messages, _, err := sm.Read(offset) + require.NoError(t, err) + require.Len(t, messages, 1) + + var msg StreamMessage + require.NoError(t, json.Unmarshal(messages[0].Data, &msg)) + assert.Equal(t, MessageTypeTask, msg.Type) +} + +func TestStreamManager_WaitForMessages(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Get current offset + offset, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Start a goroutine to broadcast a message after a delay + go func() { + time.Sleep(50 * time.Millisecond) + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + sm.BroadcastSession(session) + }() + + // Wait for messages + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + messages, timedOut, err := sm.WaitForMessages(ctx, offset, 500*time.Millisecond) + require.NoError(t, err) + assert.False(t, timedOut) + require.Len(t, messages, 1) + + var msg StreamMessage + require.NoError(t, json.Unmarshal(messages[0].Data, &msg)) + assert.Equal(t, MessageTypeSession, msg.Type) +} + +func TestStreamManager_WaitForMessages_Timeout(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Get current offset + offset, err := sm.GetCurrentOffset() + require.NoError(t, err) + + // Wait with short timeout, no messages will arrive + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + messages, timedOut, err := sm.WaitForMessages(ctx, offset, 50*time.Millisecond) + require.NoError(t, err) + assert.True(t, timedOut) + assert.Empty(t, messages) +} + +func TestStreamManager_GetCurrentState(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast some state + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + task := &Task{ + ID: "task-1", + SessionID: "sess-1", + Order: 0, + Content: "Task one", + Status: TaskStatusPending, + } + require.NoError(t, sm.BroadcastTask(task)) + + req := &InputRequest{ + ID: "input-1", + SessionID: "sess-1", + Iteration: 1, + Question: "Question?", + Responded: false, + Response: nil, + } + require.NoError(t, sm.BroadcastInputRequest(req)) + + // Get current state + sessions, tasks, inputRequests := sm.GetCurrentState() + + assert.Len(t, sessions, 1) + assert.Equal(t, "sess-1", sessions[0].ID) + + assert.Len(t, tasks, 1) + assert.Equal(t, "task-1", tasks[0].ID) + + assert.Len(t, inputRequests, 1) + assert.Equal(t, "input-1", inputRequests[0].ID) +} + +func TestStreamManager_GetCurrentState_AfterDelete(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast a session + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Delete the session + require.NoError(t, sm.BroadcastDelete("sessions", "sess-1")) + + // Get current state + sessions, _, _ := sm.GetCurrentState() + assert.Empty(t, sessions) +} + +func TestStreamManager_GetCurrentState_UpdatesExisting(t *testing.T) { + sm, err := NewStreamManager() + require.NoError(t, err) + defer sm.Close() + + // Broadcast initial session + session := &Session{ + ID: "sess-1", + Repo: "user/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, sm.BroadcastSession(session)) + + // Broadcast updated session + session.Status = SessionStatusNeedsInput + session.Iteration = 2 + require.NoError(t, sm.BroadcastSession(session)) + + // Get current state (should have updated session) + sessions, _, _ := sm.GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, SessionStatusNeedsInput, sessions[0].Status) + assert.Equal(t, 2, sessions[0].Iteration) +} + +func TestSessionStatus_Values(t *testing.T) { + assert.Equal(t, SessionStatus("running"), SessionStatusRunning) + assert.Equal(t, SessionStatus("needs_input"), SessionStatusNeedsInput) + assert.Equal(t, SessionStatus("blocked"), SessionStatusBlocked) + assert.Equal(t, SessionStatus("done"), SessionStatusDone) +} + +func TestTaskStatus_Values(t *testing.T) { + assert.Equal(t, TaskStatus("pending"), TaskStatusPending) + assert.Equal(t, TaskStatus("in_progress"), TaskStatusInProgress) + assert.Equal(t, TaskStatus("completed"), TaskStatusCompleted) +} + +func TestMessageType_Values(t *testing.T) { + assert.Equal(t, MessageType("session"), MessageTypeSession) + assert.Equal(t, MessageType("task"), MessageTypeTask) + assert.Equal(t, MessageType("claude_event"), MessageTypeClaudeEvent) + assert.Equal(t, MessageType("input_request"), MessageTypeInputRequest) + assert.Equal(t, MessageType("delete"), MessageTypeDelete) +} From 98ea87d455aa40a83bf25c4f5ff39f98a1af36f2 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:26:35 +0000 Subject: [PATCH 06/19] feat(server): implement HTTP endpoints for web server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the following HTTP endpoints: - /auth POST: password authentication (existing, now with integration tests) - /stream GET: Durable Streams endpoint with catch-up, long-poll, and SSE modes - /input POST: user input submission for NEEDS_INPUT handling - / GET: static file serving (placeholder for web assets) Also adds: - Auth middleware for protected endpoints using Bearer token - GetPendingInput() method for retrieving web client responses - Comprehensive integration tests for all endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/server/server.go | 326 ++++++++++++++++++++++- internal/server/server_test.go | 455 +++++++++++++++++++++++++++++++++ 2 files changed, 777 insertions(+), 4 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 6783644..c23e2ff 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,13 +7,17 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" + "io" "net" "net/http" + "strings" "sync" "time" + "github.com/durable-streams/durable-streams/packages/caddy-plugin/store" "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" ) @@ -34,6 +38,9 @@ type Server struct { // Durable Streams streams *StreamManager + // Pending user inputs from web client + pendingInputs map[string]string // request_id -> response + // Lifecycle started bool } @@ -170,11 +177,40 @@ func (s *Server) ListenAddr() string { // setupRoutes configures the HTTP routes. func (s *Server) setupRoutes(mux *http.ServeMux) { + // Public endpoint mux.HandleFunc("/auth", s.handleAuth) - // Additional routes will be added in future tasks: - // mux.HandleFunc("/stream", s.handleStream) - // mux.HandleFunc("/input", s.handleInput) - // mux.HandleFunc("/", s.handleStatic) + + // Protected endpoints + mux.HandleFunc("/stream", s.withAuth(s.handleStream)) + mux.HandleFunc("/input", s.withAuth(s.handleInput)) + mux.HandleFunc("/", s.handleStatic) // Static assets are public for initial page load +} + +// withAuth wraps a handler with authentication middleware. +func (s *Server) withAuth(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "authorization required", http.StatusUnauthorized) + return + } + + // Expect "Bearer " format + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + http.Error(w, "invalid authorization format", http.StatusUnauthorized) + return + } + + token := strings.TrimPrefix(authHeader, bearerPrefix) + if !s.ValidateToken(token) { + http.Error(w, "invalid or expired token", http.StatusUnauthorized) + return + } + + handler(w, r) + } } // VerifyPassword checks if the provided password matches the stored hash. @@ -249,6 +285,288 @@ func (s *Server) cleanupExpiredTokens(ctx context.Context) { } } +// Protocol header names for Durable Streams +const ( + headerStreamNextOffset = "Stream-Next-Offset" + headerStreamUpToDate = "Stream-Up-To-Date" + headerStreamCursor = "Stream-Cursor" +) + +// handleStream handles GET /stream for Durable Streams. +// It supports catch-up, long-poll, and SSE modes. +func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Set CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Expose-Headers", "Stream-Next-Offset, Stream-Up-To-Date, Stream-Cursor") + + // Get stream path + path := s.streams.StreamPath() + + // Parse offset from query + query := r.URL.Query() + offsetStr := query.Get("offset") + + var offset store.Offset + var err error + if offsetStr == "" { + offset = store.Offset{} // Start from beginning + } else { + offset, err = store.ParseOffset(offsetStr) + if err != nil { + http.Error(w, "invalid offset", http.StatusBadRequest) + return + } + } + + // Check for live mode + liveMode := query.Get("live") + + // Get current offset to check if we're at the tail + currentOffset, err := s.streams.GetCurrentOffset() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Handle offset=now + if offset.IsNow() { + offset = currentOffset + } + + // Handle SSE mode + if liveMode == "sse" { + s.handleSSE(w, r, path, offset) + return + } + + // Read messages + messages, upToDate, err := s.streams.Read(offset) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Handle long-poll mode + if liveMode == "long-poll" && len(messages) == 0 { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + messages, upToDate, err = s.streams.WaitForMessages(ctx, offset, 30*time.Second) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Timeout - return 204 with current offset + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerStreamNextOffset, offset.String()) + w.Header().Set(headerStreamUpToDate, "true") + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + + // Calculate next offset + nextOffset := offset + if len(messages) > 0 { + nextOffset = messages[len(messages)-1].Offset + } else { + nextOffset = currentOffset + } + + // Set response headers + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerStreamNextOffset, nextOffset.String()) + if upToDate || len(messages) == 0 { + w.Header().Set(headerStreamUpToDate, "true") + } + + // Format response as JSON array + body := formatJSONResponse(messages) + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +// handleSSE handles Server-Sent Events streaming. +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request, path string, offset store.Offset) { + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ctx := r.Context() + currentOffset := offset + sentInitialControl := false + + // Reconnect interval (60 seconds) + reconnectTimer := time.NewTimer(60 * time.Second) + defer reconnectTimer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-reconnectTimer.C: + return + default: + // Read any available messages + messages, upToDate, err := s.streams.Read(currentOffset) + if err != nil { + return + } + + if len(messages) > 0 { + // Send data event + body := formatJSONResponse(messages) + fmt.Fprintf(w, "event: data\n") + // Handle line terminators for SSE safety + for _, line := range strings.Split(string(body), "\n") { + fmt.Fprintf(w, "data:%s\n", line) + } + fmt.Fprintf(w, "\n") + + // Update current offset + currentOffset = messages[len(messages)-1].Offset + + // Send control event + control := map[string]interface{}{ + "streamNextOffset": currentOffset.String(), + } + if upToDate { + control["upToDate"] = true + } + controlJSON, _ := json.Marshal(control) + fmt.Fprintf(w, "event: control\n") + fmt.Fprintf(w, "data:%s\n\n", controlJSON) + + flusher.Flush() + sentInitialControl = true + } else if !sentInitialControl { + // Send initial control event + tailOffset, _ := s.streams.GetCurrentOffset() + control := map[string]interface{}{ + "streamNextOffset": tailOffset.String(), + "upToDate": true, + } + controlJSON, _ := json.Marshal(control) + fmt.Fprintf(w, "event: control\n") + fmt.Fprintf(w, "data:%s\n\n", controlJSON) + + flusher.Flush() + sentInitialControl = true + } + + // Wait for more data (100ms polling) + waitCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + s.streams.WaitForMessages(waitCtx, currentOffset, 100*time.Millisecond) + cancel() + } + } +} + +// formatJSONResponse formats messages as a JSON array. +func formatJSONResponse(messages []store.Message) []byte { + if len(messages) == 0 { + return []byte("[]") + } + return store.FormatJSONResponse(messages) +} + +// handleInput handles POST /input for user responses. +func (s *Server) handleInput(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse JSON body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var req struct { + RequestID string `json:"request_id"` + Response string `json:"response"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + if req.RequestID == "" { + http.Error(w, "request_id is required", http.StatusBadRequest) + return + } + + // Store the input response for the session to handle + // Note: Actual implementation of writing to response.json will be done + // in the loop integration task. For now, we just acknowledge receipt. + s.mu.Lock() + if s.pendingInputs == nil { + s.pendingInputs = make(map[string]string) + } + s.pendingInputs[req.RequestID] = req.Response + s.mu.Unlock() + + // Return success + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"received"}`) +} + +// GetPendingInput retrieves and removes a pending input response. +func (s *Server) GetPendingInput(requestID string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.pendingInputs == nil { + return "", false + } + response, ok := s.pendingInputs[requestID] + if ok { + delete(s.pendingInputs, requestID) + } + return response, ok +} + +// handleStatic handles GET / for serving static web assets. +// For now, returns a simple placeholder. Asset embedding will be done in the next task. +func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { + // For now, return a simple HTML page indicating the web client is not yet built + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + +Wisp + +

Wisp Remote Access

+

Web client not yet built. This is a placeholder.

+ +`)) + return + } + + // Return 404 for other paths + http.NotFound(w, r) +} + // handleAuth handles POST /auth for password authentication. func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index aff1f4d..4cd6533 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -3,10 +3,13 @@ package server import ( "context" "encoding/json" + "fmt" + "io" "net/http" "net/http/httptest" "net/url" "strings" + "sync" "testing" "time" @@ -445,3 +448,455 @@ func TestAuthEndToEnd(t *testing.T) { t.Errorf("expected status %d for wrong password, got %d", http.StatusUnauthorized, resp2.StatusCode) } } + +// Helper to get an authenticated token for tests +func getAuthToken(t *testing.T, addr string) string { + t.Helper() + form := url.Values{} + form.Add("password", testPassword) + resp, err := http.PostForm("http://"+addr+"/auth", form) + if err != nil { + t.Fatalf("failed to authenticate: %v", err) + } + defer resp.Body.Close() + + var response struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + return response.Token +} + +func TestAuthMiddleware(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + t.Run("no auth header", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode) + } + }) + + t.Run("invalid auth format", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Basic sometoken") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode) + } + }) + + t.Run("invalid token", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer invalid-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode) + } + }) + + t.Run("valid token", func(t *testing.T) { + token := getAuthToken(t, addr) + + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + // Should get through auth (200 or other non-401 status) + if resp.StatusCode == http.StatusUnauthorized { + t.Errorf("expected authenticated request to succeed") + } + }) +} + +func TestStreamEndpoint(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("wrong method", func(t *testing.T) { + req, _ := http.NewRequest("POST", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode) + } + }) + + t.Run("empty stream", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + // Check headers + if resp.Header.Get("Stream-Next-Offset") == "" { + t.Error("expected Stream-Next-Offset header") + } + if resp.Header.Get("Stream-Up-To-Date") != "true" { + t.Error("expected Stream-Up-To-Date header to be true for empty stream") + } + + // Check body is empty JSON array + body, _ := io.ReadAll(resp.Body) + if string(body) != "[]" { + t.Errorf("expected empty array, got %s", string(body)) + } + }) + + t.Run("with messages", func(t *testing.T) { + // Broadcast a session to the stream + session := &Session{ + ID: "test-session", + Repo: "test/repo", + Branch: "main", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-01T00:00:00Z", + } + if err := server.Streams().BroadcastSession(session); err != nil { + t.Fatalf("failed to broadcast: %v", err) + } + + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "test-session") { + t.Errorf("expected body to contain session, got %s", string(body)) + } + }) + + t.Run("invalid offset", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=invalid", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode) + } + }) +} + +func TestInputEndpoint(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("wrong method", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/input", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode) + } + }) + + t.Run("invalid JSON", func(t *testing.T) { + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader("not json")) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode) + } + }) + + t.Run("missing request_id", func(t *testing.T) { + body := `{"response": "test response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, resp.StatusCode) + } + }) + + t.Run("valid input", func(t *testing.T) { + body := `{"request_id": "req-123", "response": "test response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Errorf("expected status %d, got %d: %s", http.StatusOK, resp.StatusCode, string(bodyBytes)) + } + + // Verify the input was stored + response, ok := server.GetPendingInput("req-123") + if !ok { + t.Error("expected pending input to be stored") + } + if response != "test response" { + t.Errorf("expected response 'test response', got '%s'", response) + } + + // Getting it again should return not found (it's been consumed) + _, ok = server.GetPendingInput("req-123") + if ok { + t.Error("expected pending input to be consumed after first get") + } + }) +} + +func TestStaticEndpoint(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + t.Run("root path", func(t *testing.T) { + resp, err := http.Get("http://" + addr + "/") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + if !strings.Contains(resp.Header.Get("Content-Type"), "text/html") { + t.Errorf("expected HTML content type, got %s", resp.Header.Get("Content-Type")) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Wisp") { + t.Error("expected body to contain 'Wisp'") + } + }) + + t.Run("index.html", func(t *testing.T) { + resp, err := http.Get("http://" + addr + "/index.html") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + }) + + t.Run("unknown path", func(t *testing.T) { + resp, err := http.Get("http://" + addr + "/unknown") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, resp.StatusCode) + } + }) +} + +func TestStreamLongPoll(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("long-poll receives new message", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Start long-poll in goroutine + resultCh := make(chan int, 1) + go func() { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=long-poll&offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + resultCh <- -1 + return + } + defer resp.Body.Close() + resultCh <- resp.StatusCode + }() + + // Wait a bit and broadcast a message + time.Sleep(100 * time.Millisecond) + task := &Task{ + ID: "task-1", + SessionID: "test-session", + Order: 1, + Content: "Test task", + Status: TaskStatusPending, + } + server.Streams().BroadcastTask(task) + + // Wait for result + select { + case status := <-resultCh: + if status != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, status) + } + case <-time.After(3 * time.Second): + t.Error("long-poll did not return in time") + } + }) +} + +func TestPendingInputConcurrency(t *testing.T) { + server := createTestServer(t) + + // Test concurrent access to pending inputs + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + reqID := fmt.Sprintf("req-%d", id) + + // Store + server.mu.Lock() + if server.pendingInputs == nil { + server.pendingInputs = make(map[string]string) + } + server.pendingInputs[reqID] = fmt.Sprintf("response-%d", id) + server.mu.Unlock() + + // Retrieve + resp, ok := server.GetPendingInput(reqID) + if !ok { + t.Errorf("expected to find input %s", reqID) + } + if resp != fmt.Sprintf("response-%d", id) { + t.Errorf("wrong response for %s", reqID) + } + }(i) + } + wg.Wait() +} From 764a63b42d6d084e2827ee1ad394bea6bd52dd53 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:32:21 +0000 Subject: [PATCH 07/19] feat(server): implement asset embedding for web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add web package with go:embed directive for web/dist/* assets. Create getAssets() function with development mode fallback (checks filesystem before using embedded assets). Update server to serve static files from the assets filesystem with proper MIME types, caching headers, and SPA support for client-side routing. - Add web/assets.go with //go:embed directive and GetAssets() function - Add web/dist/index.html placeholder for initial build - Update Server struct to include assets fs.FS field - Implement handleStatic to serve files from embedded/dev assets - Add getContentType() for MIME type detection - Add isImmutableAsset() for cache control of hashed assets - Add comprehensive tests for asset serving 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/server/server.go | 194 ++++++++++++++++++++++++++++++--- internal/server/server_test.go | 173 +++++++++++++++++++++++++++++ web/assets.go | 54 +++++++++ web/assets_test.go | 71 ++++++++++++ web/dist/.gitkeep | 0 web/dist/index.html | 31 ++++++ 6 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 web/assets.go create mode 100644 web/assets_test.go create mode 100644 web/dist/.gitkeep create mode 100644 web/dist/index.html diff --git a/internal/server/server.go b/internal/server/server.go index c23e2ff..c2b2d84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net" "net/http" "strings" @@ -20,6 +21,7 @@ import ( "github.com/durable-streams/durable-streams/packages/caddy-plugin/store" "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" + "github.com/thruflo/wisp/web" ) // Server represents the web server for remote access to wisp sessions. @@ -41,6 +43,9 @@ type Server struct { // Pending user inputs from web client pendingInputs map[string]string // request_id -> response + // Static assets filesystem + assets fs.FS + // Lifecycle started bool } @@ -49,6 +54,7 @@ type Server struct { type Config struct { Port int PasswordHash string + Assets fs.FS // Optional: static assets filesystem. If nil, uses embedded assets. } // NewServer creates a new Server instance. @@ -65,11 +71,18 @@ func NewServer(cfg *Config) (*Server, error) { return nil, fmt.Errorf("failed to create stream manager: %w", err) } + // Use provided assets or default to embedded web assets + assets := cfg.Assets + if assets == nil { + assets = web.GetAssets("") + } + return &Server{ port: cfg.Port, passwordHash: cfg.PasswordHash, tokens: make(map[string]time.Time), streams: streams, + assets: assets, }, nil } @@ -545,26 +558,175 @@ func (s *Server) GetPendingInput(requestID string) (string, bool) { return response, ok } -// handleStatic handles GET / for serving static web assets. -// For now, returns a simple placeholder. Asset embedding will be done in the next task. +// handleStatic handles GET requests for serving static web assets. +// It serves files from the embedded or development assets filesystem. +// For SPA support, requests for non-existent paths return index.html. func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { - // For now, return a simple HTML page indicating the web client is not yet built - if r.URL.Path == "/" || r.URL.Path == "/index.html" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - w.Write([]byte(` - -Wisp - -

Wisp Remote Access

-

Web client not yet built. This is a placeholder.

- -`)) + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Clean the path and remove leading slash + path := r.URL.Path + if path == "/" { + path = "index.html" + } else { + path = strings.TrimPrefix(path, "/") + } + + // Try to open the requested file + file, err := s.assets.Open(path) + if err != nil { + // File not found - for SPA support, serve index.html for HTML requests + // This allows client-side routing to work + if strings.Contains(r.Header.Get("Accept"), "text/html") || path == "" { + s.serveFile(w, r, "index.html") + return + } + http.NotFound(w, r) + return + } + file.Close() + + // Serve the file + s.serveFile(w, r, path) +} + +// serveFile serves a file from the assets filesystem. +func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, path string) { + file, err := s.assets.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + // Get file info for size and modification time + stat, err := file.Stat() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Don't serve directories + if stat.IsDir() { + // Try index.html in the directory + indexPath := path + "/index.html" + if path == "" || path == "." { + indexPath = "index.html" + } + s.serveFile(w, r, indexPath) + return + } + + // Set content type based on extension + contentType := getContentType(path) + w.Header().Set("Content-Type", contentType) + + // Set cache headers for static assets + if isImmutableAsset(path) { + // Hashed assets can be cached indefinitely + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + // HTML and other files should be revalidated + w.Header().Set("Cache-Control", "no-cache") + } + + // Read and serve the file content + content, err := io.ReadAll(file) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) return } - // Return 404 for other paths - http.NotFound(w, r) + http.ServeContent(w, r, path, stat.ModTime(), strings.NewReader(string(content))) +} + +// getContentType returns the MIME type for a file based on its extension. +func getContentType(path string) string { + // Common web asset types + switch { + case strings.HasSuffix(path, ".html"): + return "text/html; charset=utf-8" + case strings.HasSuffix(path, ".css"): + return "text/css; charset=utf-8" + case strings.HasSuffix(path, ".js"): + return "application/javascript; charset=utf-8" + case strings.HasSuffix(path, ".json"): + return "application/json; charset=utf-8" + case strings.HasSuffix(path, ".svg"): + return "image/svg+xml" + case strings.HasSuffix(path, ".png"): + return "image/png" + case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"): + return "image/jpeg" + case strings.HasSuffix(path, ".gif"): + return "image/gif" + case strings.HasSuffix(path, ".ico"): + return "image/x-icon" + case strings.HasSuffix(path, ".woff"): + return "font/woff" + case strings.HasSuffix(path, ".woff2"): + return "font/woff2" + case strings.HasSuffix(path, ".ttf"): + return "font/ttf" + case strings.HasSuffix(path, ".webp"): + return "image/webp" + default: + return "application/octet-stream" + } +} + +// isImmutableAsset returns true if the asset path looks like a hashed asset +// that can be cached indefinitely (e.g., main.abc123.js, index-BcD123.js). +func isImmutableAsset(path string) bool { + // Get the filename without extension + // Vite-style: index-BcD123.js (hash after hyphen) + // Webpack-style: index.abc123.js (hash as middle segment) + + // Get just the filename + lastSlash := strings.LastIndex(path, "/") + if lastSlash >= 0 { + path = path[lastSlash+1:] + } + + // Remove extension + dotIdx := strings.LastIndex(path, ".") + if dotIdx <= 0 { + return false + } + nameWithoutExt := path[:dotIdx] + + // Check for Vite-style: name-HASH (hyphen separator) + hyphenIdx := strings.LastIndex(nameWithoutExt, "-") + if hyphenIdx > 0 { + hash := nameWithoutExt[hyphenIdx+1:] + if len(hash) >= 6 && len(hash) <= 16 && isAlphanumeric(hash) { + return true + } + } + + // Check for Webpack-style: name.HASH (dot separator with 3+ parts) + parts := strings.Split(nameWithoutExt, ".") + if len(parts) >= 2 { + hash := parts[len(parts)-1] + if len(hash) >= 6 && len(hash) <= 16 && isAlphanumeric(hash) { + return true + } + } + + return false +} + +// isAlphanumeric returns true if the string contains only alphanumeric characters. +func isAlphanumeric(s string) bool { + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return len(s) > 0 } // handleAuth handles POST /auth for password authentication. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4cd6533..3c06b14 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -900,3 +900,176 @@ func TestPendingInputConcurrency(t *testing.T) { } wg.Wait() } + +// Tests for static asset serving + +func TestHandleStatic(t *testing.T) { + server := createTestServer(t) + + tests := []struct { + name string + method string + path string + accept string + wantStatus int + wantType string + wantContains string + wantCacheCtrl string + }{ + { + name: "root returns index.html", + method: "GET", + path: "/", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + wantContains: "Wisp", + wantCacheCtrl: "no-cache", + }, + { + name: "explicit index.html", + method: "GET", + path: "/index.html", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + wantContains: "", + wantCacheCtrl: "no-cache", + }, + { + name: "HEAD request works", + method: "HEAD", + path: "/", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + }, + { + name: "POST not allowed", + method: "POST", + path: "/", + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "PUT not allowed", + method: "PUT", + path: "/", + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "nonexistent file returns 404", + method: "GET", + path: "/nonexistent.txt", + wantStatus: http.StatusNotFound, + }, + { + name: "nonexistent path with HTML accept returns index.html (SPA support)", + method: "GET", + path: "/some/spa/route", + accept: "text/html", + wantStatus: http.StatusOK, + wantType: "text/html; charset=utf-8", + wantContains: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + if tt.accept != "" { + req.Header.Set("Accept", tt.accept) + } + w := httptest.NewRecorder() + + server.handleStatic(w, req) + + resp := w.Result() + if resp.StatusCode != tt.wantStatus { + t.Errorf("expected status %d, got %d", tt.wantStatus, resp.StatusCode) + } + + if tt.wantType != "" { + contentType := resp.Header.Get("Content-Type") + if contentType != tt.wantType { + t.Errorf("expected Content-Type %q, got %q", tt.wantType, contentType) + } + } + + if tt.wantContains != "" { + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), tt.wantContains) { + t.Errorf("expected body to contain %q, got %q", tt.wantContains, string(body)) + } + } + + if tt.wantCacheCtrl != "" { + cacheCtrl := resp.Header.Get("Cache-Control") + if cacheCtrl != tt.wantCacheCtrl { + t.Errorf("expected Cache-Control %q, got %q", tt.wantCacheCtrl, cacheCtrl) + } + } + }) + } +} + +func TestGetContentType(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"index.html", "text/html; charset=utf-8"}, + {"style.css", "text/css; charset=utf-8"}, + {"app.js", "application/javascript; charset=utf-8"}, + {"data.json", "application/json; charset=utf-8"}, + {"icon.svg", "image/svg+xml"}, + {"photo.png", "image/png"}, + {"photo.jpg", "image/jpeg"}, + {"photo.jpeg", "image/jpeg"}, + {"animation.gif", "image/gif"}, + {"favicon.ico", "image/x-icon"}, + {"font.woff", "font/woff"}, + {"font.woff2", "font/woff2"}, + {"font.ttf", "font/ttf"}, + {"image.webp", "image/webp"}, + {"unknown.xyz", "application/octet-stream"}, + {"nested/path/file.html", "text/html; charset=utf-8"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := getContentType(tt.path) + if got != tt.want { + t.Errorf("getContentType(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestIsImmutableAsset(t *testing.T) { + tests := []struct { + path string + want bool + }{ + // Hashed assets (immutable) + {"index-BcD123aF.js", true}, + {"style-abc456.css", true}, + {"vendor.def789.js", true}, + + // Non-hashed assets (mutable) + {"index.html", false}, + {"style.css", false}, + {"app.js", false}, + {"favicon.ico", false}, + + // Edge cases + {"a.b", false}, // too short + {"file.short.js", false}, // 5 chars is too short for hash + {"file.abc123.js", true}, // 6 chars is minimum for hash + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := isImmutableAsset(tt.path) + if got != tt.want { + t.Errorf("isImmutableAsset(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} diff --git a/web/assets.go b/web/assets.go new file mode 100644 index 0000000..ddfc7aa --- /dev/null +++ b/web/assets.go @@ -0,0 +1,54 @@ +// Package web provides embedded web assets for the wisp remote access client. +// +// The dist/ directory is embedded at build time. During development, +// if dist/ exists on the filesystem, it will be used instead, allowing +// for hot reloading of the web client. +package web + +import ( + "embed" + "io/fs" + "os" + "path/filepath" +) + +// assets holds the embedded web client files from dist/. +// When the web client is built (npm run build), these files +// are embedded into the binary. +// +//go:embed dist/* +var assets embed.FS + +// GetAssets returns a filesystem containing the web client assets. +// In development mode (when the dist directory exists on the filesystem +// relative to the working directory), it returns the live filesystem +// for hot reloading. In production, it returns the embedded assets. +// +// The devPath parameter specifies the path to check for development mode. +// If empty, it defaults to "./web/dist" (relative to the working directory). +func GetAssets(devPath string) fs.FS { + if devPath == "" { + devPath = "./web/dist" + } + + // Development: check filesystem first for hot reloading + if stat, err := os.Stat(devPath); err == nil && stat.IsDir() { + return os.DirFS(devPath) + } + + // Production: use embedded assets + // The assets FS has "dist/" prefix, so we need to use Sub + subFS, err := fs.Sub(assets, "dist") + if err != nil { + // This should never happen with properly embedded assets + panic("failed to access embedded web assets: " + err.Error()) + } + return subFS +} + +// GetAssetsWithBase returns a filesystem for assets, checking for development +// mode at a path relative to the given base directory. +func GetAssetsWithBase(baseDir string) fs.FS { + devPath := filepath.Join(baseDir, "web", "dist") + return GetAssets(devPath) +} diff --git a/web/assets_test.go b/web/assets_test.go new file mode 100644 index 0000000..0a41cdd --- /dev/null +++ b/web/assets_test.go @@ -0,0 +1,71 @@ +package web + +import ( + "io/fs" + "testing" +) + +func TestGetAssets(t *testing.T) { + // Test that GetAssets returns a valid filesystem + assets := GetAssets("") + if assets == nil { + t.Fatal("GetAssets returned nil") + } + + // Verify we can read the embedded index.html + file, err := assets.Open("index.html") + if err != nil { + t.Fatalf("failed to open index.html: %v", err) + } + defer file.Close() + + // Verify it's a file, not a directory + stat, err := file.Stat() + if err != nil { + t.Fatalf("failed to stat index.html: %v", err) + } + if stat.IsDir() { + t.Error("index.html is a directory, expected file") + } + if stat.Size() == 0 { + t.Error("index.html is empty") + } +} + +func TestGetAssetsWithBase(t *testing.T) { + // Test GetAssetsWithBase with a non-existent path falls back to embedded + assets := GetAssetsWithBase("/nonexistent/path") + if assets == nil { + t.Fatal("GetAssetsWithBase returned nil") + } + + // Should still work with embedded assets + file, err := assets.Open("index.html") + if err != nil { + t.Fatalf("failed to open index.html: %v", err) + } + file.Close() +} + +func TestAssetsFSInterface(t *testing.T) { + // Verify GetAssets returns a proper fs.FS implementation + var assets fs.FS = GetAssets("") + + // Test that we can walk the filesystem + var fileCount int + err := fs.WalkDir(assets, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + fileCount++ + } + return nil + }) + if err != nil { + t.Fatalf("failed to walk assets: %v", err) + } + if fileCount == 0 { + t.Error("no files found in assets") + } +} diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..f4f43db --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,31 @@ + + + + + + Wisp - Remote Access + + + +
+

Wisp Remote Access

+

Web client placeholder. Run npm run build in the web/ directory to build the full client.

+
+ + From aaf49aa7f4a5404e7004816ea9df8b091e167b4f Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:37:23 +0000 Subject: [PATCH 08/19] feat(loop): integrate server with loop for state broadcasting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional server parameter to Loop struct and implement real-time state broadcasting to web clients: - Add Server field to Loop and LoopOptions for optional web server - Broadcast session state after each sync (status, iteration, tasks) - Broadcast Claude output lines as ClaudeEvents in real-time - Broadcast InputRequest on NEEDS_INPUT with polling for web responses - Map internal state statuses to server.SessionStatus types - Track event sequence numbers per iteration for ClaudeEvents - First-response-wins concurrency handling for TUI/web input Tests: - TestBroadcastState: verifies session and task state broadcasting - TestBroadcastStateNeedsInput/Blocked/Done: status mapping tests - TestBroadcastStateNoServer: graceful no-op without server - TestBroadcastClaudeEvent: verifies JSON event broadcasting - TestBroadcastClaudeEventSkipsInvalidJSON: filters invalid lines - TestBroadcastInputRequest: verifies input request broadcasting - TestBroadcastInputResponded: verifies response updates - TestLoopWithServerOption: verifies server option storage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/loop/loop.go | 207 ++++++++++++- internal/loop/loop_test.go | 577 +++++++++++++++++++++++++++++++++++++ 2 files changed, 780 insertions(+), 4 deletions(-) diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 9fe0f12..713517c 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -12,6 +12,7 @@ import ( "time" "github.com/thruflo/wisp/internal/config" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" @@ -98,12 +99,14 @@ type Loop struct { cfg *config.Config session *config.Session tui *tui.TUI - repoPath string // Path on Sprite: /var/local/wisp/repos// - wispPath string // Path on Sprite: /.wisp + server *server.Server // Optional web server for remote access + repoPath string // Path on Sprite: /var/local/wisp/repos// + wispPath string // Path on Sprite: /.wisp iteration int startTime time.Time templateDir string // Local path to templates claudeCfg ClaudeConfig // Claude command configuration + eventSeq int // Sequence counter for Claude events } // LoopOptions holds configuration for creating a Loop instance. @@ -115,6 +118,7 @@ type LoopOptions struct { Config *config.Config Session *config.Session TUI *tui.TUI + Server *server.Server // Optional: web server for remote access RepoPath string TemplateDir string StartTime time.Time // Optional: for deterministic time-based testing @@ -160,6 +164,7 @@ func NewLoopWithOptions(opts LoopOptions) *Loop { cfg: opts.Config, session: opts.Session, tui: opts.TUI, + server: opts.Server, repoPath: opts.RepoPath, wispPath: filepath.Join(opts.RepoPath, ".wisp"), templateDir: opts.TemplateDir, @@ -225,6 +230,9 @@ func (l *Loop) Run(ctx context.Context) Result { } } + // Broadcast state to web clients if server is running + l.broadcastState(iterResult) + // Record history if err := l.recordHistory(ctx, iterResult); err != nil { // Non-fatal, continue @@ -398,6 +406,9 @@ func (l *Loop) streamOutput(ctx context.Context, r io.ReadCloser) error { l.tui.AppendTailLine(displayLine) l.tui.Update() } + + // Broadcast Claude event to web clients if server is running + l.broadcastClaudeEvent(line) } return scanner.Err() @@ -634,8 +645,28 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { l.tui.Bell() l.tui.Update() - // Wait for user input + // Broadcast input request to web clients and get request ID + requestID := l.broadcastInputRequest(st.Question) + + // Wait for user input from TUI or web client for { + // Check for web client input + if l.server != nil && requestID != "" { + if response, ok := l.server.GetPendingInput(requestID); ok { + // Web client provided input + if err := l.sync.WriteResponseToSprite(ctx, l.session.SpriteName, response); err != nil { + return Result{ + Reason: ExitReasonCrash, + Iterations: l.iteration, + Error: fmt.Errorf("failed to write response: %w", err), + } + } + // Broadcast that input request was responded + l.broadcastInputResponded(requestID, response) + return Result{Reason: ExitReasonUnknown} + } + } + select { case <-ctx.Done(): return Result{Reason: ExitReasonBackground, Iterations: l.iteration} @@ -651,7 +682,8 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { Error: fmt.Errorf("failed to write response: %w", err), } } - // Continue loop + // Broadcast that input request was responded + l.broadcastInputResponded(requestID, action.Input) return Result{Reason: ExitReasonUnknown} case tui.ActionCancelInput: @@ -666,6 +698,10 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { case tui.ActionBackground, tui.ActionQuit: return Result{Reason: ExitReasonBackground, Iterations: l.iteration} } + + case <-time.After(100 * time.Millisecond): + // Poll for web client input periodically + continue } } } @@ -757,3 +793,166 @@ var ( errUserKill = errors.New("user killed session") errUserBackground = errors.New("user backgrounded session") ) + +// broadcastState broadcasts session and task state to web clients. +// This is called after each state sync to keep web clients up to date. +func (l *Loop) broadcastState(st *state.State) { + if l.server == nil { + return + } + + streams := l.server.Streams() + if streams == nil { + return + } + + // Map state.State status to server.SessionStatus + var status server.SessionStatus + switch st.Status { + case state.StatusDone: + status = server.SessionStatusDone + case state.StatusNeedsInput: + status = server.SessionStatusNeedsInput + case state.StatusBlocked: + status = server.SessionStatusBlocked + default: + status = server.SessionStatusRunning + } + + // Broadcast session state + session := &server.Session{ + ID: l.session.Branch, + Repo: l.session.Repo, + Branch: l.session.Branch, + Spec: l.session.Spec, + Status: status, + Iteration: l.iteration, + StartedAt: l.session.StartedAt.Format(time.RFC3339), + } + streams.BroadcastSession(session) + + // Broadcast tasks + tasks, err := l.store.LoadTasks(l.session.Branch) + if err != nil { + return + } + + for i, t := range tasks { + var taskStatus server.TaskStatus + if t.Passes { + taskStatus = server.TaskStatusCompleted + } else { + // The first incomplete task is considered in progress + foundIncomplete := false + for j := 0; j < i; j++ { + if !tasks[j].Passes { + foundIncomplete = true + break + } + } + if !foundIncomplete && !t.Passes { + taskStatus = server.TaskStatusInProgress + } else { + taskStatus = server.TaskStatusPending + } + } + + task := &server.Task{ + ID: fmt.Sprintf("%s-task-%d", l.session.Branch, i), + SessionID: l.session.Branch, + Order: i, + Content: t.Description, + Status: taskStatus, + } + streams.BroadcastTask(task) + } +} + +// broadcastClaudeEvent broadcasts a Claude output line to web clients. +func (l *Loop) broadcastClaudeEvent(line string) { + if l.server == nil { + return + } + + streams := l.server.Streams() + if streams == nil { + return + } + + // Skip empty lines + line = strings.TrimSpace(line) + if line == "" { + return + } + + // Try to parse as JSON to pass through raw SDK message + var sdkMessage any + if err := json.Unmarshal([]byte(line), &sdkMessage); err != nil { + // Not valid JSON, skip + return + } + + // Increment sequence for this iteration + l.eventSeq++ + + event := &server.ClaudeEvent{ + ID: fmt.Sprintf("%s-%d-%d", l.session.Branch, l.iteration, l.eventSeq), + SessionID: l.session.Branch, + Iteration: l.iteration, + Sequence: l.eventSeq, + Message: sdkMessage, + Timestamp: time.Now().Format(time.RFC3339), + } + + streams.BroadcastClaudeEvent(event) +} + +// broadcastInputRequest broadcasts an input request to web clients. +// Returns the request ID for tracking responses. +func (l *Loop) broadcastInputRequest(question string) string { + if l.server == nil { + return "" + } + + streams := l.server.Streams() + if streams == nil { + return "" + } + + requestID := fmt.Sprintf("%s-%d-input", l.session.Branch, l.iteration) + + req := &server.InputRequest{ + ID: requestID, + SessionID: l.session.Branch, + Iteration: l.iteration, + Question: question, + Responded: false, + Response: nil, + } + + streams.BroadcastInputRequest(req) + return requestID +} + +// broadcastInputResponded broadcasts that an input request has been responded to. +func (l *Loop) broadcastInputResponded(requestID, response string) { + if l.server == nil || requestID == "" { + return + } + + streams := l.server.Streams() + if streams == nil { + return + } + + req := &server.InputRequest{ + ID: requestID, + SessionID: l.session.Branch, + Iteration: l.iteration, + Question: "", // Question is not needed for update + Responded: true, + Response: &response, + } + + streams.BroadcastInputRequest(req) +} diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index 3a58b77..67068c2 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -12,7 +12,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" @@ -1291,3 +1293,578 @@ func TestLoopRunWithInjectedStartTime(t *testing.T) { assert.Equal(t, ExitReasonBackground, result.Reason) }) } + +// TestBroadcastState tests that session and task state is broadcast to the server. +func TestBroadcastState(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-broadcast" + + // Create session + startTime := time.Now() + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + Spec: "docs/spec.md", + SpriteName: "wisp-test", + StartedAt: startTime, + } + require.NoError(t, store.CreateSession(session)) + + // Create tasks + tasks := []state.Task{ + {Description: "Task 1", Passes: true}, + {Description: "Task 2", Passes: false}, + {Description: "Task 3", Passes: false}, + } + require.NoError(t, store.SaveTasks(branch, tasks)) + + // Create server + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, // Auto-assign port + PasswordHash: hash, + }) + require.NoError(t, err) + + // Create loop with server + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{ + Limits: config.Limits{ + MaxIterations: 10, + }, + } + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + StartTime: startTime, + }) + loop.iteration = 5 + + // Test broadcastState + st := &state.State{ + Status: state.StatusContinue, + Summary: "Working on task 2", + } + + loop.broadcastState(st) + + // Verify session was broadcast + sessions, broadcastTasks, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1, "expected 1 session") + assert.Equal(t, branch, sessions[0].ID) + assert.Equal(t, "org/repo", sessions[0].Repo) + assert.Equal(t, branch, sessions[0].Branch) + assert.Equal(t, "docs/spec.md", sessions[0].Spec) + assert.Equal(t, server.SessionStatusRunning, sessions[0].Status) + assert.Equal(t, 5, sessions[0].Iteration) + + // Verify tasks were broadcast + require.Len(t, broadcastTasks, 3, "expected 3 tasks") + + // Find tasks by order + tasksByOrder := make(map[int]*server.Task) + for _, task := range broadcastTasks { + tasksByOrder[task.Order] = task + } + + // Task 0 (completed) + assert.Equal(t, server.TaskStatusCompleted, tasksByOrder[0].Status) + assert.Equal(t, "Task 1", tasksByOrder[0].Content) + + // Task 1 (in progress - first incomplete) + assert.Equal(t, server.TaskStatusInProgress, tasksByOrder[1].Status) + assert.Equal(t, "Task 2", tasksByOrder[1].Content) + + // Task 2 (pending) + assert.Equal(t, server.TaskStatusPending, tasksByOrder[2].Status) + assert.Equal(t, "Task 3", tasksByOrder[2].Content) +} + +// TestBroadcastStateNeedsInput tests that NEEDS_INPUT status is correctly broadcast. +func TestBroadcastStateNeedsInput(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-needs-input-broadcast" + + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + SpriteName: "wisp-test", + StartedAt: time.Now(), + } + require.NoError(t, store.CreateSession(session)) + + // Create server + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Test with NEEDS_INPUT state + st := &state.State{ + Status: state.StatusNeedsInput, + Summary: "Awaiting input", + Question: "What database?", + } + + loop.broadcastState(st) + + sessions, _, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, server.SessionStatusNeedsInput, sessions[0].Status) +} + +// TestBroadcastStateBlocked tests that BLOCKED status is correctly broadcast. +func TestBroadcastStateBlocked(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-blocked-broadcast" + + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + SpriteName: "wisp-test", + StartedAt: time.Now(), + } + require.NoError(t, store.CreateSession(session)) + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + st := &state.State{ + Status: state.StatusBlocked, + Error: "Missing dependency", + } + + loop.broadcastState(st) + + sessions, _, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, server.SessionStatusBlocked, sessions[0].Status) +} + +// TestBroadcastStateDone tests that DONE status is correctly broadcast. +func TestBroadcastStateDone(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-done-broadcast" + + session := &config.Session{ + Repo: "org/repo", + Branch: branch, + SpriteName: "wisp-test", + StartedAt: time.Now(), + } + require.NoError(t, store.CreateSession(session)) + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + st := &state.State{ + Status: state.StatusDone, + Summary: "All tasks completed", + } + + loop.broadcastState(st) + + sessions, _, _ := srv.Streams().GetCurrentState() + require.Len(t, sessions, 1) + assert.Equal(t, server.SessionStatusDone, sessions[0].Status) +} + +// TestBroadcastStateNoServer tests that broadcastState is a no-op without server. +func TestBroadcastStateNoServer(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-no-server" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + // No server + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: nil, // No server + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Should not panic + st := &state.State{Status: state.StatusContinue} + loop.broadcastState(st) +} + +// TestBroadcastClaudeEvent tests that Claude events are broadcast to the server. +func TestBroadcastClaudeEvent(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-claude-event" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + loop.iteration = 3 + + // Broadcast a Claude event (stream-json format) + jsonLine := `{"type":"assistant","message":{"content":[{"type":"text","text":"Hello, world!"}]}}` + loop.broadcastClaudeEvent(jsonLine) + + // Sequence should increment + assert.Equal(t, 1, loop.eventSeq) + + // Broadcast another event + jsonLine2 := `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}` + loop.broadcastClaudeEvent(jsonLine2) + + assert.Equal(t, 2, loop.eventSeq) +} + +// TestBroadcastClaudeEventSkipsInvalidJSON tests that non-JSON lines are skipped. +func TestBroadcastClaudeEventSkipsInvalidJSON(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-invalid-json" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Broadcast invalid JSON - should not increment sequence + loop.broadcastClaudeEvent("not valid json") + assert.Equal(t, 0, loop.eventSeq) + + // Empty line + loop.broadcastClaudeEvent("") + assert.Equal(t, 0, loop.eventSeq) + + // Whitespace only + loop.broadcastClaudeEvent(" ") + assert.Equal(t, 0, loop.eventSeq) +} + +// TestBroadcastInputRequest tests that input requests are broadcast. +func TestBroadcastInputRequest(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-input-request" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + loop.iteration = 7 + + // Broadcast input request + requestID := loop.broadcastInputRequest("What database should we use?") + + assert.Equal(t, "test-input-request-7-input", requestID) + + // Verify input request was broadcast + _, _, inputRequests := srv.Streams().GetCurrentState() + require.Len(t, inputRequests, 1) + assert.Equal(t, requestID, inputRequests[0].ID) + assert.Equal(t, branch, inputRequests[0].SessionID) + assert.Equal(t, 7, inputRequests[0].Iteration) + assert.Equal(t, "What database should we use?", inputRequests[0].Question) + assert.False(t, inputRequests[0].Responded) + assert.Nil(t, inputRequests[0].Response) +} + +// TestBroadcastInputResponded tests that input responses are broadcast. +func TestBroadcastInputResponded(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-input-responded" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + loop.iteration = 4 + + // First broadcast the request + requestID := loop.broadcastInputRequest("Question?") + + // Then broadcast the response + loop.broadcastInputResponded(requestID, "Answer!") + + // Verify input request was updated + _, _, inputRequests := srv.Streams().GetCurrentState() + require.Len(t, inputRequests, 1) + assert.True(t, inputRequests[0].Responded) + require.NotNil(t, inputRequests[0].Response) + assert.Equal(t, "Answer!", *inputRequests[0].Response) +} + +// TestBroadcastInputRequestNoServer tests that broadcastInputRequest handles no server. +func TestBroadcastInputRequestNoServer(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + branch := "test-no-server-input" + + session := &config.Session{ + Branch: branch, + SpriteName: "wisp-test", + } + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + + // No server + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: nil, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + // Should return empty string + requestID := loop.broadcastInputRequest("Question?") + assert.Equal(t, "", requestID) + + // broadcastInputResponded should be a no-op + loop.broadcastInputResponded("some-id", "response") // Should not panic +} + +// TestLoopWithServerOption tests that NewLoopWithOptions correctly stores server. +func TestLoopWithServerOption(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + store := state.NewStore(tmpDir) + + hash, err := auth.HashPassword("testpass") + require.NoError(t, err) + srv, err := server.NewServer(&server.Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + mockClient := NewMockSpriteClient() + syncMgr := state.NewSyncManager(mockClient, store) + mockTUI := tui.NewTUI(io.Discard) + cfg := &config.Config{} + session := &config.Session{Branch: "test-branch"} + + loop := NewLoopWithOptions(LoopOptions{ + Client: mockClient, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: mockTUI, + Server: srv, + RepoPath: "/var/local/wisp/repos/org/repo", + }) + + assert.NotNil(t, loop.server) + assert.Equal(t, srv, loop.server) +} From 6b224c4bade07c4bb331e6234f0beffcde7b6337 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:54:04 +0000 Subject: [PATCH 09/19] feat(web): set up web client project structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create React + TypeScript web client with Vite for wisp remote access: - Add package.json with dependencies (react, zod, @durable-streams/state, @tanstack/react-db) - Configure Vite build with React plugin and dev proxy - Set up TypeScript config - Create db/ with schema, store, and optimistic actions - Create components: Login, Dashboard, Session, TaskList, OutputLog, InputPrompt - Add CSS styles for all components - Build produces dist/ for Go embedding 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 5 +- web/dist/.gitkeep | 0 web/dist/assets/index-3svPJstS.css | 1 + web/dist/assets/index-BdGL3ZQM.js | 109 ++ web/dist/favicon.svg | 4 + web/dist/index.html | 39 +- web/index.html | 13 + web/package-lock.json | 2329 ++++++++++++++++++++++++++++ web/package.json | 27 + web/public/favicon.svg | 4 + web/src/App.tsx | 85 + web/src/components/Dashboard.tsx | 42 + web/src/components/InputPrompt.tsx | 83 + web/src/components/Login.tsx | 64 + web/src/components/OutputLog.tsx | 87 ++ web/src/components/Session.tsx | 79 + web/src/components/TaskList.tsx | 37 + web/src/db/actions.ts | 33 + web/src/db/schema.ts | 53 + web/src/db/store.ts | 25 + web/src/main.tsx | 10 + web/src/styles/main.css | 513 ++++++ web/tsconfig.json | 20 + web/vite.config.ts | 18 + 24 files changed, 3651 insertions(+), 29 deletions(-) delete mode 100644 web/dist/.gitkeep create mode 100644 web/dist/assets/index-3svPJstS.css create mode 100644 web/dist/assets/index-BdGL3ZQM.js create mode 100644 web/dist/favicon.svg create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.tsx create mode 100644 web/src/components/Dashboard.tsx create mode 100644 web/src/components/InputPrompt.tsx create mode 100644 web/src/components/Login.tsx create mode 100644 web/src/components/OutputLog.tsx create mode 100644 web/src/components/Session.tsx create mode 100644 web/src/components/TaskList.tsx create mode 100644 web/src/db/actions.ts create mode 100644 web/src/db/schema.ts create mode 100644 web/src/db/store.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles/main.css create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index d97cdc2..1da5c87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .DS_Store .claude/settings.local.json .wisp/ -wisp \ No newline at end of file +wisp + +# Web client +web/node_modules/ \ No newline at end of file diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/dist/assets/index-3svPJstS.css b/web/dist/assets/index-3svPJstS.css new file mode 100644 index 0000000..2c221e4 --- /dev/null +++ b/web/dist/assets/index-3svPJstS.css @@ -0,0 +1 @@ +*,*:before,*:after{box-sizing:border-box}:root{--color-bg: #f5f5f5;--color-surface: #ffffff;--color-text: #333333;--color-text-muted: #666666;--color-border: #e0e0e0;--color-primary: #2563eb;--color-primary-hover: #1d4ed8;--color-success: #16a34a;--color-warning: #ca8a04;--color-error: #dc2626;--radius: 8px;--shadow: 0 1px 3px rgba(0, 0, 0, .1)}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5;background:var(--color-bg);color:var(--color-text)}.app{min-height:100vh;display:flex;flex-direction:column}.app-header{display:flex;justify-content:space-between;align-items:center;padding:1rem 2rem;background:var(--color-surface);border-bottom:1px solid var(--color-border);box-shadow:var(--shadow)}.app-header h1{margin:0;font-size:1.5rem}.logout-btn{padding:.5rem 1rem;border:1px solid var(--color-border);border-radius:var(--radius);background:transparent;cursor:pointer}.logout-btn:hover{background:var(--color-bg)}main{flex:1;padding:2rem;max-width:1400px;margin:0 auto;width:100%}.login{min-height:100vh;display:flex;justify-content:center;align-items:center;background:var(--color-bg)}.login-card{background:var(--color-surface);padding:2rem;border-radius:var(--radius);box-shadow:var(--shadow);text-align:center;width:100%;max-width:320px}.login-card h1{margin:0 0 .5rem}.login-card p{margin:0 0 1.5rem;color:var(--color-text-muted)}.login-card form{display:flex;flex-direction:column;gap:1rem}.login-card input{padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.login-card button{padding:.75rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.login-card button:hover:not(:disabled){background:var(--color-primary-hover)}.login-card button:disabled{opacity:.5;cursor:not-allowed}.error-message{color:var(--color-error);font-size:.875rem}.loading,.error{min-height:100vh;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:1rem}.error button{padding:.5rem 1rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;cursor:pointer}.dashboard h2{margin:0 0 1.5rem}.no-sessions{color:var(--color-text-muted)}.session-list{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}.session-card{display:block;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);text-decoration:none;color:inherit;border-left:4px solid transparent;transition:transform .1s,box-shadow .1s}.session-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px #00000026}.session-card.status-running{border-left-color:var(--color-primary)}.session-card.status-needs_input{border-left-color:var(--color-warning)}.session-card.status-blocked{border-left-color:var(--color-error)}.session-card.status-done{border-left-color:var(--color-success)}.session-repo{font-weight:600;margin-bottom:.25rem}.session-branch{color:var(--color-text-muted);font-size:.875rem;margin-bottom:.5rem}.session-meta{display:flex;justify-content:space-between;align-items:center}.status-badge{display:inline-block;padding:.25rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;text-transform:uppercase}.status-badge.running{background:#dbeafe;color:var(--color-primary)}.status-badge.needs_input{background:#fef3c7;color:var(--color-warning)}.status-badge.blocked{background:#fee2e2;color:var(--color-error)}.status-badge.done{background:#dcfce7;color:var(--color-success)}.iteration{font-size:.875rem;color:var(--color-text-muted)}.session-loading{text-align:center;padding:2rem}.session-header{display:flex;justify-content:space-between;align-items:flex-start;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:1.5rem}.session-info h2{margin:0 0 .25rem}.session-info .branch{margin:0;color:var(--color-text-muted)}.session-status{display:flex;flex-direction:column;align-items:flex-end;gap:.5rem}.session-content{display:grid;grid-template-columns:300px 1fr;gap:1.5rem}@media(max-width:768px){.session-content{grid-template-columns:1fr}}.session-sidebar{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem}.task-list h3{margin:0 0 1rem}.task-list ul{list-style:none;margin:0;padding:0}.task-list .task{display:flex;align-items:flex-start;gap:.5rem;padding:.5rem 0;border-bottom:1px solid var(--color-border)}.task-list .task:last-child{border-bottom:none}.task-status-icon{flex-shrink:0;width:1.25rem;text-align:center}.task.status-completed .task-status-icon{color:var(--color-success)}.task.status-in_progress .task-status-icon{color:var(--color-primary)}.task.status-pending .task-status-icon{color:var(--color-text-muted)}.task-content{flex:1;word-break:break-word}.task.status-completed .task-content{color:var(--color-text-muted)}.no-tasks{color:var(--color-text-muted);margin:0}.session-main{display:flex;flex-direction:column;gap:1rem}.output-log{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;flex:1;min-height:400px;display:flex;flex-direction:column}.output-log h3{margin:0 0 1rem}.output-content{flex:1;overflow-y:auto;font-family:SF Mono,Monaco,Consolas,monospace;font-size:.875rem;background:#1a1a1a;color:#e0e0e0;padding:1rem;border-radius:4px;max-height:500px}.event{margin-bottom:.5rem;padding:.5rem;border-radius:4px}.event.assistant{background:#2563eb1a}.event.tool-result{background:#0003}.event.tool-result pre{margin:0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.event.result{background:#16a34a33;color:#4ade80}.event.system{color:var(--color-text-muted);font-size:.75rem}.content-text{white-space:pre-wrap;word-break:break-word}.content-tool-use{background:#0003;padding:.5rem;border-radius:4px;margin:.25rem 0}.content-tool-use summary{cursor:pointer;color:#93c5fd}.content-tool-use pre{margin:.5rem 0 0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.input-prompt{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;border:2px solid var(--color-warning)}.input-prompt .question{margin:0 0 1rem;font-weight:600}.input-prompt .input-row{display:flex;gap:.5rem}.input-prompt input{flex:1;padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.input-prompt button{padding:.75rem 1.5rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.input-prompt button:hover:not(:disabled){background:var(--color-primary-hover)}.input-prompt button:disabled{opacity:.5;cursor:not-allowed}.input-prompt.responded{border-color:var(--color-success);background:#dcfce7}.input-prompt.responded .response{margin:0;color:var(--color-success)} diff --git a/web/dist/assets/index-BdGL3ZQM.js b/web/dist/assets/index-BdGL3ZQM.js new file mode 100644 index 0000000..61bee71 --- /dev/null +++ b/web/dist/assets/index-BdGL3ZQM.js @@ -0,0 +1,109 @@ +var A_=Object.defineProperty;var Gg=n=>{throw TypeError(n)};var M_=(n,t,s)=>t in n?A_(n,t,{enumerable:!0,configurable:!0,writable:!0,value:s}):n[t]=s;var He=(n,t,s)=>M_(n,typeof t!="symbol"?t+"":t,s),fh=(n,t,s)=>t.has(n)||Gg("Cannot "+s);var x=(n,t,s)=>(fh(n,t,"read from private field"),s?s.call(n):t.get(n)),ne=(n,t,s)=>t.has(n)?Gg("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(n):t.set(n,s),ee=(n,t,s,r)=>(fh(n,t,"write to private field"),r?r.call(n,s):t.set(n,s),s),V=(n,t,s)=>(fh(n,t,"access private method"),s);var hh=(n,t,s,r)=>({set _(l){ee(n,t,l,s)},get _(){return x(n,t,r)}});(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const c of u.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&r(c)}).observe(document,{childList:!0,subtree:!0});function s(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=s(l);fetch(l.href,u)}})();function D_(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var dh={exports:{}},La={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Yg;function k_(){if(Yg)return La;Yg=1;var n=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function s(r,l,u){var c=null;if(u!==void 0&&(c=""+u),l.key!==void 0&&(c=""+l.key),"key"in l){u={};for(var d in l)d!=="key"&&(u[d]=l[d])}else u=l;return l=u.ref,{$$typeof:n,type:r,key:c,ref:l!==void 0?l:null,props:u}}return La.Fragment=t,La.jsx=s,La.jsxs=s,La}var Jg;function N_(){return Jg||(Jg=1,dh.exports=k_()),dh.exports}var Y=N_(),ph={exports:{}},pe={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Qg;function j_(){if(Qg)return pe;Qg=1;var n=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),l=Symbol.for("react.profiler"),u=Symbol.for("react.consumer"),c=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),m=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),w=Symbol.iterator;function S(R){return R===null||typeof R!="object"?null:(R=w&&R[w]||R["@@iterator"],typeof R=="function"?R:null)}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},E=Object.assign,O={};function M(R,I,X){this.props=R,this.context=I,this.refs=O,this.updater=X||b}M.prototype.isReactComponent={},M.prototype.setState=function(R,I){if(typeof R!="object"&&typeof R!="function"&&R!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,R,I,"setState")},M.prototype.forceUpdate=function(R){this.updater.enqueueForceUpdate(this,R,"forceUpdate")};function L(){}L.prototype=M.prototype;function N(R,I,X){this.props=R,this.context=I,this.refs=O,this.updater=X||b}var K=N.prototype=new L;K.constructor=N,E(K,M.prototype),K.isPureReactComponent=!0;var F=Array.isArray;function P(){}var J={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function W(R,I,X){var re=X.ref;return{$$typeof:n,type:R,key:I,ref:re!==void 0?re:null,props:X}}function le(R,I){return W(R.type,I,R.props)}function ie(R){return typeof R=="object"&&R!==null&&R.$$typeof===n}function he(R){var I={"=":"=0",":":"=2"};return"$"+R.replace(/[=:]/g,function(X){return I[X]})}var ve=/\/+/g;function xt(R,I){return typeof R=="object"&&R!==null&&R.key!=null?he(""+R.key):I.toString(36)}function at(R){switch(R.status){case"fulfilled":return R.value;case"rejected":throw R.reason;default:switch(typeof R.status=="string"?R.then(P,P):(R.status="pending",R.then(function(I){R.status==="pending"&&(R.status="fulfilled",R.value=I)},function(I){R.status==="pending"&&(R.status="rejected",R.reason=I)})),R.status){case"fulfilled":return R.value;case"rejected":throw R.reason}}throw R}function B(R,I,X,re,de){var ge=typeof R;(ge==="undefined"||ge==="boolean")&&(R=null);var ze=!1;if(R===null)ze=!0;else switch(ge){case"bigint":case"string":case"number":ze=!0;break;case"object":switch(R.$$typeof){case n:case t:ze=!0;break;case g:return ze=R._init,B(ze(R._payload),I,X,re,de)}}if(ze)return de=de(R),ze=re===""?"."+xt(R,0):re,F(de)?(X="",ze!=null&&(X=ze.replace(ve,"$&/")+"/"),B(de,I,X,"",function(ns){return ns})):de!=null&&(ie(de)&&(de=le(de,X+(de.key==null||R&&R.key===de.key?"":(""+de.key).replace(ve,"$&/")+"/")+ze)),I.push(de)),1;ze=0;var ct=re===""?".":re+":";if(F(R))for(var Le=0;Le>>1,Oe=B[Te];if(0>>1;Tel(X,oe))rel(de,X)?(B[Te]=de,B[re]=oe,Te=re):(B[Te]=X,B[I]=oe,Te=I);else if(rel(de,oe))B[Te]=de,B[re]=oe,Te=re;else break e}}return Q}function l(B,Q){var oe=B.sortIndex-Q.sortIndex;return oe!==0?oe:B.id-Q.id}if(n.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var u=performance;n.unstable_now=function(){return u.now()}}else{var c=Date,d=c.now();n.unstable_now=function(){return c.now()-d}}var p=[],m=[],g=1,y=null,w=3,S=!1,b=!1,E=!1,O=!1,M=typeof setTimeout=="function"?setTimeout:null,L=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function K(B){for(var Q=s(m);Q!==null;){if(Q.callback===null)r(m);else if(Q.startTime<=B)r(m),Q.sortIndex=Q.expirationTime,t(p,Q);else break;Q=s(m)}}function F(B){if(E=!1,K(B),!b)if(s(p)!==null)b=!0,P||(P=!0,he());else{var Q=s(m);Q!==null&&at(F,Q.startTime-B)}}var P=!1,J=-1,te=5,W=-1;function le(){return O?!0:!(n.unstable_now()-WB&&le());){var Te=y.callback;if(typeof Te=="function"){y.callback=null,w=y.priorityLevel;var Oe=Te(y.expirationTime<=B);if(B=n.unstable_now(),typeof Oe=="function"){y.callback=Oe,K(B),Q=!0;break t}y===s(p)&&r(p),K(B)}else r(p);y=s(p)}if(y!==null)Q=!0;else{var R=s(m);R!==null&&at(F,R.startTime-B),Q=!1}}break e}finally{y=null,w=oe,S=!1}Q=void 0}}finally{Q?he():P=!1}}}var he;if(typeof N=="function")he=function(){N(ie)};else if(typeof MessageChannel<"u"){var ve=new MessageChannel,xt=ve.port2;ve.port1.onmessage=ie,he=function(){xt.postMessage(null)}}else he=function(){M(ie,0)};function at(B,Q){J=M(function(){B(n.unstable_now())},Q)}n.unstable_IdlePriority=5,n.unstable_ImmediatePriority=1,n.unstable_LowPriority=4,n.unstable_NormalPriority=3,n.unstable_Profiling=null,n.unstable_UserBlockingPriority=2,n.unstable_cancelCallback=function(B){B.callback=null},n.unstable_forceFrameRate=function(B){0>B||125Te?(B.sortIndex=oe,t(m,B),s(p)===null&&B===s(m)&&(E?(L(J),J=-1):E=!0,at(F,oe-Te))):(B.sortIndex=Oe,t(p,B),b||S||(b=!0,P||(P=!0,he()))),B},n.unstable_shouldYield=le,n.unstable_wrapCallback=function(B){var Q=w;return function(){var oe=w;w=Q;try{return B.apply(this,arguments)}finally{w=oe}}}})(gh)),gh}var Pg;function B_(){return Pg||(Pg=1,yh.exports=U_()),yh.exports}var vh={exports:{}},Tt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Wg;function L_(){if(Wg)return Tt;Wg=1;var n=ud();function t(p){var m="https://react.dev/errors/"+p;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(t){console.error(t)}}return n(),vh.exports=L_(),vh.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var tv;function Z_(){if(tv)return Ha;tv=1;var n=B_(),t=ud(),s=H_();function r(e){var i="https://react.dev/errors/"+e;if(1Oe||(e.current=Te[Oe],Te[Oe]=null,Oe--)}function X(e,i){Oe++,Te[Oe]=e.current,e.current=i}var re=R(null),de=R(null),ge=R(null),ze=R(null);function ct(e,i){switch(X(ge,i),X(de,e),X(re,null),i.nodeType){case 9:case 11:e=(e=i.documentElement)&&(e=e.namespaceURI)?mg(e):0;break;default:if(e=i.tagName,i=i.namespaceURI)i=mg(i),e=yg(i,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}I(re),X(re,e)}function Le(){I(re),I(de),I(ge)}function ns(e){e.memoizedState!==null&&X(ze,e);var i=re.current,a=yg(i,e.type);i!==a&&(X(de,e),X(re,a))}function Bs(e){de.current===e&&(I(re),I(de)),ze.current===e&&(I(ze),Na._currentValue=oe)}var Dl,kl;function is(e){if(Dl===void 0)try{throw Error()}catch(a){var i=a.stack.trim().match(/\n( *(at )?)/);Dl=i&&i[1]||"",kl=-1)":-1f||T[o]!==D[f]){var H=` +`+T[o].replace(" at new "," at ");return e.displayName&&H.includes("")&&(H=H.replace("",e.displayName)),H}while(1<=o&&0<=f);break}}}finally{Yu=!1,Error.prepareStackTrace=a}return(a=e?e.displayName||e.name:"")?is(a):""}function lb(e,i){switch(e.tag){case 26:case 27:case 5:return is(e.type);case 16:return is("Lazy");case 13:return e.child!==i&&i!==null?is("Suspense Fallback"):is("Suspense");case 19:return is("SuspenseList");case 0:case 15:return Ju(e.type,!1);case 11:return Ju(e.type.render,!1);case 1:return Ju(e.type,!0);case 31:return is("Activity");default:return""}}function Gd(e){try{var i="",a=null;do i+=lb(e,a),a=e,e=e.return;while(e);return i}catch(o){return` +Error generating stack: `+o.message+` +`+o.stack}}var Qu=Object.prototype.hasOwnProperty,Xu=n.unstable_scheduleCallback,Fu=n.unstable_cancelCallback,ob=n.unstable_shouldYield,ub=n.unstable_requestPaint,Yt=n.unstable_now,cb=n.unstable_getCurrentPriorityLevel,Yd=n.unstable_ImmediatePriority,Jd=n.unstable_UserBlockingPriority,Nl=n.unstable_NormalPriority,fb=n.unstable_LowPriority,Qd=n.unstable_IdlePriority,hb=n.log,db=n.unstable_setDisableYieldValue,Gr=null,Jt=null;function mi(e){if(typeof hb=="function"&&db(e),Jt&&typeof Jt.setStrictMode=="function")try{Jt.setStrictMode(Gr,e)}catch{}}var Qt=Math.clz32?Math.clz32:yb,pb=Math.log,mb=Math.LN2;function yb(e){return e>>>=0,e===0?32:31-(pb(e)/mb|0)|0}var jl=256,Ul=262144,Bl=4194304;function ss(e){var i=e&42;if(i!==0)return i;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ll(e,i,a){var o=e.pendingLanes;if(o===0)return 0;var f=0,h=e.suspendedLanes,v=e.pingedLanes;e=e.warmLanes;var _=o&134217727;return _!==0?(o=_&~h,o!==0?f=ss(o):(v&=_,v!==0?f=ss(v):a||(a=_&~e,a!==0&&(f=ss(a))))):(_=o&~h,_!==0?f=ss(_):v!==0?f=ss(v):a||(a=o&~e,a!==0&&(f=ss(a)))),f===0?0:i!==0&&i!==f&&(i&h)===0&&(h=f&-f,a=i&-i,h>=a||h===32&&(a&4194048)!==0)?i:f}function Yr(e,i){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&i)===0}function gb(e,i){switch(e){case 1:case 2:case 4:case 8:case 64:return i+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return i+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Xd(){var e=Bl;return Bl<<=1,(Bl&62914560)===0&&(Bl=4194304),e}function Pu(e){for(var i=[],a=0;31>a;a++)i.push(e);return i}function Jr(e,i){e.pendingLanes|=i,i!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function vb(e,i,a,o,f,h){var v=e.pendingLanes;e.pendingLanes=a,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=a,e.entangledLanes&=a,e.errorRecoveryDisabledLanes&=a,e.shellSuspendCounter=0;var _=e.entanglements,T=e.expirationTimes,D=e.hiddenUpdates;for(a=v&~a;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var xb=/[\n"\\]/g;function ln(e){return e.replace(xb,function(i){return"\\"+i.charCodeAt(0).toString(16)+" "})}function sc(e,i,a,o,f,h,v,_){e.name="",v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"?e.type=v:e.removeAttribute("type"),i!=null?v==="number"?(i===0&&e.value===""||e.value!=i)&&(e.value=""+an(i)):e.value!==""+an(i)&&(e.value=""+an(i)):v!=="submit"&&v!=="reset"||e.removeAttribute("value"),i!=null?rc(e,v,an(i)):a!=null?rc(e,v,an(a)):o!=null&&e.removeAttribute("value"),f==null&&h!=null&&(e.defaultChecked=!!h),f!=null&&(e.checked=f&&typeof f!="function"&&typeof f!="symbol"),_!=null&&typeof _!="function"&&typeof _!="symbol"&&typeof _!="boolean"?e.name=""+an(_):e.removeAttribute("name")}function up(e,i,a,o,f,h,v,_){if(h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"&&(e.type=h),i!=null||a!=null){if(!(h!=="submit"&&h!=="reset"||i!=null)){ic(e);return}a=a!=null?""+an(a):"",i=i!=null?""+an(i):a,_||i===e.value||(e.value=i),e.defaultValue=i}o=o??f,o=typeof o!="function"&&typeof o!="symbol"&&!!o,e.checked=_?e.checked:!!o,e.defaultChecked=!!o,v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(e.name=v),ic(e)}function rc(e,i,a){i==="number"&&$l(e.ownerDocument)===e||e.defaultValue===""+a||(e.defaultValue=""+a)}function Is(e,i,a,o){if(e=e.options,i){i={};for(var f=0;f"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),cc=!1;if(In)try{var Pr={};Object.defineProperty(Pr,"passive",{get:function(){cc=!0}}),window.addEventListener("test",Pr,Pr),window.removeEventListener("test",Pr,Pr)}catch{cc=!1}var gi=null,fc=null,Il=null;function yp(){if(Il)return Il;var e,i=fc,a=i.length,o,f="value"in gi?gi.value:gi.textContent,h=f.length;for(e=0;e=ta),_p=" ",Ep=!1;function xp(e,i){switch(e){case"keyup":return Pb.indexOf(i.keyCode)!==-1;case"keydown":return i.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Tp(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Ys=!1;function ew(e,i){switch(e){case"compositionend":return Tp(i);case"keypress":return i.which!==32?null:(Ep=!0,_p);case"textInput":return e=i.data,e===_p&&Ep?null:e;default:return null}}function tw(e,i){if(Ys)return e==="compositionend"||!yc&&xp(e,i)?(e=yp(),Il=fc=gi=null,Ys=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(i.ctrlKey||i.altKey||i.metaKey)||i.ctrlKey&&i.altKey){if(i.char&&1=i)return{node:a,offset:i-e};e=o}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=kp(a)}}function jp(e,i){return e&&i?e===i?!0:e&&e.nodeType===3?!1:i&&i.nodeType===3?jp(e,i.parentNode):"contains"in e?e.contains(i):e.compareDocumentPosition?!!(e.compareDocumentPosition(i)&16):!1:!1}function Up(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var i=$l(e.document);i instanceof e.HTMLIFrameElement;){try{var a=typeof i.contentWindow.location.href=="string"}catch{a=!1}if(a)e=i.contentWindow;else break;i=$l(e.document)}return i}function Sc(e){var i=e&&e.nodeName&&e.nodeName.toLowerCase();return i&&(i==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||i==="textarea"||e.contentEditable==="true")}var uw=In&&"documentMode"in document&&11>=document.documentMode,Js=null,bc=null,ra=null,wc=!1;function Bp(e,i,a){var o=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;wc||Js==null||Js!==$l(o)||(o=Js,"selectionStart"in o&&Sc(o)?o={start:o.selectionStart,end:o.selectionEnd}:(o=(o.ownerDocument&&o.ownerDocument.defaultView||window).getSelection(),o={anchorNode:o.anchorNode,anchorOffset:o.anchorOffset,focusNode:o.focusNode,focusOffset:o.focusOffset}),ra&&sa(ra,o)||(ra=o,o=Bo(bc,"onSelect"),0>=v,f-=v,On=1<<32-Qt(i)+f|a<ye?(_e=ae,ae=null):_e=ae.sibling;var Ce=k(C,ae,A[ye],Z);if(Ce===null){ae===null&&(ae=_e);break}e&&ae&&Ce.alternate===null&&i(C,ae),z=h(Ce,z,ye),Re===null?ue=Ce:Re.sibling=Ce,Re=Ce,ae=_e}if(ye===A.length)return a(C,ae),xe&&Vn(C,ye),ue;if(ae===null){for(;yeye?(_e=ae,ae=null):_e=ae.sibling;var Hi=k(C,ae,Ce.value,Z);if(Hi===null){ae===null&&(ae=_e);break}e&&ae&&Hi.alternate===null&&i(C,ae),z=h(Hi,z,ye),Re===null?ue=Hi:Re.sibling=Hi,Re=Hi,ae=_e}if(Ce.done)return a(C,ae),xe&&Vn(C,ye),ue;if(ae===null){for(;!Ce.done;ye++,Ce=A.next())Ce=q(C,Ce.value,Z),Ce!==null&&(z=h(Ce,z,ye),Re===null?ue=Ce:Re.sibling=Ce,Re=Ce);return xe&&Vn(C,ye),ue}for(ae=o(ae);!Ce.done;ye++,Ce=A.next())Ce=U(ae,C,ye,Ce.value,Z),Ce!==null&&(e&&Ce.alternate!==null&&ae.delete(Ce.key===null?ye:Ce.key),z=h(Ce,z,ye),Re===null?ue=Ce:Re.sibling=Ce,Re=Ce);return e&&ae.forEach(function(C_){return i(C,C_)}),xe&&Vn(C,ye),ue}function Ue(C,z,A,Z){if(typeof A=="object"&&A!==null&&A.type===E&&A.key===null&&(A=A.props.children),typeof A=="object"&&A!==null){switch(A.$$typeof){case S:e:{for(var ue=A.key;z!==null;){if(z.key===ue){if(ue=A.type,ue===E){if(z.tag===7){a(C,z.sibling),Z=f(z,A.props.children),Z.return=C,C=Z;break e}}else if(z.elementType===ue||typeof ue=="object"&&ue!==null&&ue.$$typeof===te&&ms(ue)===z.type){a(C,z.sibling),Z=f(z,A.props),fa(Z,A),Z.return=C,C=Z;break e}a(C,z);break}else i(C,z);z=z.sibling}A.type===E?(Z=cs(A.props.children,C.mode,Z,A.key),Z.return=C,C=Z):(Z=Wl(A.type,A.key,A.props,null,C.mode,Z),fa(Z,A),Z.return=C,C=Z)}return v(C);case b:e:{for(ue=A.key;z!==null;){if(z.key===ue)if(z.tag===4&&z.stateNode.containerInfo===A.containerInfo&&z.stateNode.implementation===A.implementation){a(C,z.sibling),Z=f(z,A.children||[]),Z.return=C,C=Z;break e}else{a(C,z);break}else i(C,z);z=z.sibling}Z=Rc(A,C.mode,Z),Z.return=C,C=Z}return v(C);case te:return A=ms(A),Ue(C,z,A,Z)}if(at(A))return se(C,z,A,Z);if(he(A)){if(ue=he(A),typeof ue!="function")throw Error(r(150));return A=ue.call(A),fe(C,z,A,Z)}if(typeof A.then=="function")return Ue(C,z,ao(A),Z);if(A.$$typeof===N)return Ue(C,z,no(C,A),Z);lo(C,A)}return typeof A=="string"&&A!==""||typeof A=="number"||typeof A=="bigint"?(A=""+A,z!==null&&z.tag===6?(a(C,z.sibling),Z=f(z,A),Z.return=C,C=Z):(a(C,z),Z=zc(A,C.mode,Z),Z.return=C,C=Z),v(C)):a(C,z)}return function(C,z,A,Z){try{ca=0;var ue=Ue(C,z,A,Z);return rr=null,ue}catch(ae){if(ae===sr||ae===so)throw ae;var Re=Ft(29,ae,null,C.mode);return Re.lanes=Z,Re.return=C,Re}finally{}}}var gs=am(!0),lm=am(!1),_i=!1;function Zc(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function $c(e,i){e=e.updateQueue,i.updateQueue===e&&(i.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ei(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function xi(e,i,a){var o=e.updateQueue;if(o===null)return null;if(o=o.shared,(Ae&2)!==0){var f=o.pending;return f===null?i.next=i:(i.next=f.next,f.next=i),o.pending=i,i=Pl(e),Kp(e,null,a),i}return Fl(e,o,i,a),Pl(e)}function ha(e,i,a){if(i=i.updateQueue,i!==null&&(i=i.shared,(a&4194048)!==0)){var o=i.lanes;o&=e.pendingLanes,a|=o,i.lanes=a,Pd(e,a)}}function qc(e,i){var a=e.updateQueue,o=e.alternate;if(o!==null&&(o=o.updateQueue,a===o)){var f=null,h=null;if(a=a.firstBaseUpdate,a!==null){do{var v={lane:a.lane,tag:a.tag,payload:a.payload,callback:null,next:null};h===null?f=h=v:h=h.next=v,a=a.next}while(a!==null);h===null?f=h=i:h=h.next=i}else f=h=i;a={baseState:o.baseState,firstBaseUpdate:f,lastBaseUpdate:h,shared:o.shared,callbacks:o.callbacks},e.updateQueue=a;return}e=a.lastBaseUpdate,e===null?a.firstBaseUpdate=i:e.next=i,a.lastBaseUpdate=i}var Ic=!1;function da(){if(Ic){var e=ir;if(e!==null)throw e}}function pa(e,i,a,o){Ic=!1;var f=e.updateQueue;_i=!1;var h=f.firstBaseUpdate,v=f.lastBaseUpdate,_=f.shared.pending;if(_!==null){f.shared.pending=null;var T=_,D=T.next;T.next=null,v===null?h=D:v.next=D,v=T;var H=e.alternate;H!==null&&(H=H.updateQueue,_=H.lastBaseUpdate,_!==v&&(_===null?H.firstBaseUpdate=D:_.next=D,H.lastBaseUpdate=T))}if(h!==null){var q=f.baseState;v=0,H=D=T=null,_=h;do{var k=_.lane&-536870913,U=k!==_.lane;if(U?(we&k)===k:(o&k)===k){k!==0&&k===nr&&(Ic=!0),H!==null&&(H=H.next={lane:0,tag:_.tag,payload:_.payload,callback:null,next:null});e:{var se=e,fe=_;k=i;var Ue=a;switch(fe.tag){case 1:if(se=fe.payload,typeof se=="function"){q=se.call(Ue,q,k);break e}q=se;break e;case 3:se.flags=se.flags&-65537|128;case 0:if(se=fe.payload,k=typeof se=="function"?se.call(Ue,q,k):se,k==null)break e;q=y({},q,k);break e;case 2:_i=!0}}k=_.callback,k!==null&&(e.flags|=64,U&&(e.flags|=8192),U=f.callbacks,U===null?f.callbacks=[k]:U.push(k))}else U={lane:k,tag:_.tag,payload:_.payload,callback:_.callback,next:null},H===null?(D=H=U,T=q):H=H.next=U,v|=k;if(_=_.next,_===null){if(_=f.shared.pending,_===null)break;U=_,_=U.next,U.next=null,f.lastBaseUpdate=U,f.shared.pending=null}}while(!0);H===null&&(T=q),f.baseState=T,f.firstBaseUpdate=D,f.lastBaseUpdate=H,h===null&&(f.shared.lanes=0),Ci|=v,e.lanes=v,e.memoizedState=q}}function om(e,i){if(typeof e!="function")throw Error(r(191,e));e.call(i)}function um(e,i){var a=e.callbacks;if(a!==null)for(e.callbacks=null,e=0;eh?h:8;var v=B.T,_={};B.T=_,uf(e,!1,i,a);try{var T=f(),D=B.S;if(D!==null&&D(_,T),T!==null&&typeof T=="object"&&typeof T.then=="function"){var H=vw(T,o);ga(e,i,H,nn(e))}else ga(e,i,o,nn(e))}catch(q){ga(e,i,{then:function(){},status:"rejected",reason:q},nn())}finally{Q.p=h,v!==null&&_.types!==null&&(v.types=_.types),B.T=v}}function xw(){}function lf(e,i,a,o){if(e.tag!==5)throw Error(r(476));var f=$m(e).queue;Zm(e,f,i,oe,a===null?xw:function(){return qm(e),a(o)})}function $m(e){var i=e.memoizedState;if(i!==null)return i;i={memoizedState:oe,baseState:oe,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Qn,lastRenderedState:oe},next:null};var a={};return i.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Qn,lastRenderedState:a},next:null},e.memoizedState=i,e=e.alternate,e!==null&&(e.memoizedState=i),i}function qm(e){var i=$m(e);i.next===null&&(i=e.alternate.memoizedState),ga(e,i.next.queue,{},nn())}function of(){return yt(Na)}function Im(){return We().memoizedState}function Km(){return We().memoizedState}function Tw(e){for(var i=e.return;i!==null;){switch(i.tag){case 24:case 3:var a=nn();e=Ei(a);var o=xi(i,e,a);o!==null&&(qt(o,i,a),ha(o,i,a)),i={cache:Uc()},e.payload=i;return}i=i.return}}function Ow(e,i,a){var o=nn();a={lane:o,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},vo(e)?Gm(i,a):(a=Tc(e,i,a,o),a!==null&&(qt(a,e,o),Ym(a,i,o)))}function Vm(e,i,a){var o=nn();ga(e,i,a,o)}function ga(e,i,a,o){var f={lane:o,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null};if(vo(e))Gm(i,f);else{var h=e.alternate;if(e.lanes===0&&(h===null||h.lanes===0)&&(h=i.lastRenderedReducer,h!==null))try{var v=i.lastRenderedState,_=h(v,a);if(f.hasEagerState=!0,f.eagerState=_,Xt(_,v))return Fl(e,i,f,0),Be===null&&Xl(),!1}catch{}finally{}if(a=Tc(e,i,f,o),a!==null)return qt(a,e,o),Ym(a,i,o),!0}return!1}function uf(e,i,a,o){if(o={lane:2,revertLane:$f(),gesture:null,action:o,hasEagerState:!1,eagerState:null,next:null},vo(e)){if(i)throw Error(r(479))}else i=Tc(e,a,o,2),i!==null&&qt(i,e,2)}function vo(e){var i=e.alternate;return e===me||i!==null&&i===me}function Gm(e,i){lr=co=!0;var a=e.pending;a===null?i.next=i:(i.next=a.next,a.next=i),e.pending=i}function Ym(e,i,a){if((a&4194048)!==0){var o=i.lanes;o&=e.pendingLanes,a|=o,i.lanes=a,Pd(e,a)}}var va={readContext:yt,use:po,useCallback:Je,useContext:Je,useEffect:Je,useImperativeHandle:Je,useLayoutEffect:Je,useInsertionEffect:Je,useMemo:Je,useReducer:Je,useRef:Je,useState:Je,useDebugValue:Je,useDeferredValue:Je,useTransition:Je,useSyncExternalStore:Je,useId:Je,useHostTransitionStatus:Je,useFormState:Je,useActionState:Je,useOptimistic:Je,useMemoCache:Je,useCacheRefresh:Je};va.useEffectEvent=Je;var Jm={readContext:yt,use:po,useCallback:function(e,i){return Ct().memoizedState=[e,i===void 0?null:i],e},useContext:yt,useEffect:Mm,useImperativeHandle:function(e,i,a){a=a!=null?a.concat([e]):null,yo(4194308,4,jm.bind(null,i,e),a)},useLayoutEffect:function(e,i){return yo(4194308,4,e,i)},useInsertionEffect:function(e,i){yo(4,2,e,i)},useMemo:function(e,i){var a=Ct();i=i===void 0?null:i;var o=e();if(vs){mi(!0);try{e()}finally{mi(!1)}}return a.memoizedState=[o,i],o},useReducer:function(e,i,a){var o=Ct();if(a!==void 0){var f=a(i);if(vs){mi(!0);try{a(i)}finally{mi(!1)}}}else f=i;return o.memoizedState=o.baseState=f,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:f},o.queue=e,e=e.dispatch=Ow.bind(null,me,e),[o.memoizedState,e]},useRef:function(e){var i=Ct();return e={current:e},i.memoizedState=e},useState:function(e){e=tf(e);var i=e.queue,a=Vm.bind(null,me,i);return i.dispatch=a,[e.memoizedState,a]},useDebugValue:rf,useDeferredValue:function(e,i){var a=Ct();return af(a,e,i)},useTransition:function(){var e=tf(!1);return e=Zm.bind(null,me,e.queue,!0,!1),Ct().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,i,a){var o=me,f=Ct();if(xe){if(a===void 0)throw Error(r(407));a=a()}else{if(a=i(),Be===null)throw Error(r(349));(we&127)!==0||mm(o,i,a)}f.memoizedState=a;var h={value:a,getSnapshot:i};return f.queue=h,Mm(gm.bind(null,o,h,e),[e]),o.flags|=2048,ur(9,{destroy:void 0},ym.bind(null,o,h,a,i),null),a},useId:function(){var e=Ct(),i=Be.identifierPrefix;if(xe){var a=zn,o=On;a=(o&~(1<<32-Qt(o)-1)).toString(32)+a,i="_"+i+"R_"+a,a=fo++,0<\/script>",h=h.removeChild(h.firstChild);break;case"select":h=typeof o.is=="string"?v.createElement("select",{is:o.is}):v.createElement("select"),o.multiple?h.multiple=!0:o.size&&(h.size=o.size);break;default:h=typeof o.is=="string"?v.createElement(f,{is:o.is}):v.createElement(f)}}h[pt]=i,h[Ut]=o;e:for(v=i.child;v!==null;){if(v.tag===5||v.tag===6)h.appendChild(v.stateNode);else if(v.tag!==4&&v.tag!==27&&v.child!==null){v.child.return=v,v=v.child;continue}if(v===i)break e;for(;v.sibling===null;){if(v.return===null||v.return===i)break e;v=v.return}v.sibling.return=v.return,v=v.sibling}i.stateNode=h;e:switch(vt(h,f,o),f){case"button":case"input":case"select":case"textarea":o=!!o.autoFocus;break e;case"img":o=!0;break e;default:o=!1}o&&Fn(i)}}return $e(i),Ef(i,i.type,e===null?null:e.memoizedProps,i.pendingProps,a),null;case 6:if(e&&i.stateNode!=null)e.memoizedProps!==o&&Fn(i);else{if(typeof o!="string"&&i.stateNode===null)throw Error(r(166));if(e=ge.current,er(i)){if(e=i.stateNode,a=i.memoizedProps,o=null,f=mt,f!==null)switch(f.tag){case 27:case 5:o=f.memoizedProps}e[pt]=i,e=!!(e.nodeValue===a||o!==null&&o.suppressHydrationWarning===!0||dg(e.nodeValue,a)),e||bi(i,!0)}else e=Lo(e).createTextNode(o),e[pt]=i,i.stateNode=e}return $e(i),null;case 31:if(a=i.memoizedState,e===null||e.memoizedState!==null){if(o=er(i),a!==null){if(e===null){if(!o)throw Error(r(318));if(e=i.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(r(557));e[pt]=i}else fs(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;$e(i),e=!1}else a=Dc(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),e=!0;if(!e)return i.flags&256?(Wt(i),i):(Wt(i),null);if((i.flags&128)!==0)throw Error(r(558))}return $e(i),null;case 13:if(o=i.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(f=er(i),o!==null&&o.dehydrated!==null){if(e===null){if(!f)throw Error(r(318));if(f=i.memoizedState,f=f!==null?f.dehydrated:null,!f)throw Error(r(317));f[pt]=i}else fs(),(i.flags&128)===0&&(i.memoizedState=null),i.flags|=4;$e(i),f=!1}else f=Dc(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=f),f=!0;if(!f)return i.flags&256?(Wt(i),i):(Wt(i),null)}return Wt(i),(i.flags&128)!==0?(i.lanes=a,i):(a=o!==null,e=e!==null&&e.memoizedState!==null,a&&(o=i.child,f=null,o.alternate!==null&&o.alternate.memoizedState!==null&&o.alternate.memoizedState.cachePool!==null&&(f=o.alternate.memoizedState.cachePool.pool),h=null,o.memoizedState!==null&&o.memoizedState.cachePool!==null&&(h=o.memoizedState.cachePool.pool),h!==f&&(o.flags|=2048)),a!==e&&a&&(i.child.flags|=8192),Eo(i,i.updateQueue),$e(i),null);case 4:return Le(),e===null&&Vf(i.stateNode.containerInfo),$e(i),null;case 10:return Yn(i.type),$e(i),null;case 19:if(I(Pe),o=i.memoizedState,o===null)return $e(i),null;if(f=(i.flags&128)!==0,h=o.rendering,h===null)if(f)ba(o,!1);else{if(Qe!==0||e!==null&&(e.flags&128)!==0)for(e=i.child;e!==null;){if(h=uo(e),h!==null){for(i.flags|=128,ba(o,!1),e=h.updateQueue,i.updateQueue=e,Eo(i,e),i.subtreeFlags=0,e=a,a=i.child;a!==null;)Vp(a,e),a=a.sibling;return X(Pe,Pe.current&1|2),xe&&Vn(i,o.treeForkCount),i.child}e=e.sibling}o.tail!==null&&Yt()>Ro&&(i.flags|=128,f=!0,ba(o,!1),i.lanes=4194304)}else{if(!f)if(e=uo(h),e!==null){if(i.flags|=128,f=!0,e=e.updateQueue,i.updateQueue=e,Eo(i,e),ba(o,!0),o.tail===null&&o.tailMode==="hidden"&&!h.alternate&&!xe)return $e(i),null}else 2*Yt()-o.renderingStartTime>Ro&&a!==536870912&&(i.flags|=128,f=!0,ba(o,!1),i.lanes=4194304);o.isBackwards?(h.sibling=i.child,i.child=h):(e=o.last,e!==null?e.sibling=h:i.child=h,o.last=h)}return o.tail!==null?(e=o.tail,o.rendering=e,o.tail=e.sibling,o.renderingStartTime=Yt(),e.sibling=null,a=Pe.current,X(Pe,f?a&1|2:a&1),xe&&Vn(i,o.treeForkCount),e):($e(i),null);case 22:case 23:return Wt(i),Vc(),o=i.memoizedState!==null,e!==null?e.memoizedState!==null!==o&&(i.flags|=8192):o&&(i.flags|=8192),o?(a&536870912)!==0&&(i.flags&128)===0&&($e(i),i.subtreeFlags&6&&(i.flags|=8192)):$e(i),a=i.updateQueue,a!==null&&Eo(i,a.retryQueue),a=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(a=e.memoizedState.cachePool.pool),o=null,i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(o=i.memoizedState.cachePool.pool),o!==a&&(i.flags|=2048),e!==null&&I(ps),null;case 24:return a=null,e!==null&&(a=e.memoizedState.cache),i.memoizedState.cache!==a&&(i.flags|=2048),Yn(et),$e(i),null;case 25:return null;case 30:return null}throw Error(r(156,i.tag))}function Mw(e,i){switch(Ac(i),i.tag){case 1:return e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 3:return Yn(et),Le(),e=i.flags,(e&65536)!==0&&(e&128)===0?(i.flags=e&-65537|128,i):null;case 26:case 27:case 5:return Bs(i),null;case 31:if(i.memoizedState!==null){if(Wt(i),i.alternate===null)throw Error(r(340));fs()}return e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 13:if(Wt(i),e=i.memoizedState,e!==null&&e.dehydrated!==null){if(i.alternate===null)throw Error(r(340));fs()}return e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 19:return I(Pe),null;case 4:return Le(),null;case 10:return Yn(i.type),null;case 22:case 23:return Wt(i),Vc(),e!==null&&I(ps),e=i.flags,e&65536?(i.flags=e&-65537|128,i):null;case 24:return Yn(et),null;case 25:return null;default:return null}}function vy(e,i){switch(Ac(i),i.tag){case 3:Yn(et),Le();break;case 26:case 27:case 5:Bs(i);break;case 4:Le();break;case 31:i.memoizedState!==null&&Wt(i);break;case 13:Wt(i);break;case 19:I(Pe);break;case 10:Yn(i.type);break;case 22:case 23:Wt(i),Vc(),e!==null&&I(ps);break;case 24:Yn(et)}}function wa(e,i){try{var a=i.updateQueue,o=a!==null?a.lastEffect:null;if(o!==null){var f=o.next;a=f;do{if((a.tag&e)===e){o=void 0;var h=a.create,v=a.inst;o=h(),v.destroy=o}a=a.next}while(a!==f)}}catch(_){De(i,i.return,_)}}function zi(e,i,a){try{var o=i.updateQueue,f=o!==null?o.lastEffect:null;if(f!==null){var h=f.next;o=h;do{if((o.tag&e)===e){var v=o.inst,_=v.destroy;if(_!==void 0){v.destroy=void 0,f=i;var T=a,D=_;try{D()}catch(H){De(f,T,H)}}}o=o.next}while(o!==h)}}catch(H){De(i,i.return,H)}}function Sy(e){var i=e.updateQueue;if(i!==null){var a=e.stateNode;try{um(i,a)}catch(o){De(e,e.return,o)}}}function by(e,i,a){a.props=Ss(e.type,e.memoizedProps),a.state=e.memoizedState;try{a.componentWillUnmount()}catch(o){De(e,i,o)}}function _a(e,i){try{var a=e.ref;if(a!==null){switch(e.tag){case 26:case 27:case 5:var o=e.stateNode;break;case 30:o=e.stateNode;break;default:o=e.stateNode}typeof a=="function"?e.refCleanup=a(o):a.current=o}}catch(f){De(e,i,f)}}function Rn(e,i){var a=e.ref,o=e.refCleanup;if(a!==null)if(typeof o=="function")try{o()}catch(f){De(e,i,f)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof a=="function")try{a(null)}catch(f){De(e,i,f)}else a.current=null}function wy(e){var i=e.type,a=e.memoizedProps,o=e.stateNode;try{e:switch(i){case"button":case"input":case"select":case"textarea":a.autoFocus&&o.focus();break e;case"img":a.src?o.src=a.src:a.srcSet&&(o.srcset=a.srcSet)}}catch(f){De(e,e.return,f)}}function xf(e,i,a){try{var o=e.stateNode;Ww(o,e.type,a,i),o[Ut]=i}catch(f){De(e,e.return,f)}}function _y(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Ni(e.type)||e.tag===4}function Tf(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||_y(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Ni(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Of(e,i,a){var o=e.tag;if(o===5||o===6)e=e.stateNode,i?(a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a).insertBefore(e,i):(i=a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a,i.appendChild(e),a=a._reactRootContainer,a!=null||i.onclick!==null||(i.onclick=qn));else if(o!==4&&(o===27&&Ni(e.type)&&(a=e.stateNode,i=null),e=e.child,e!==null))for(Of(e,i,a),e=e.sibling;e!==null;)Of(e,i,a),e=e.sibling}function xo(e,i,a){var o=e.tag;if(o===5||o===6)e=e.stateNode,i?a.insertBefore(e,i):a.appendChild(e);else if(o!==4&&(o===27&&Ni(e.type)&&(a=e.stateNode),e=e.child,e!==null))for(xo(e,i,a),e=e.sibling;e!==null;)xo(e,i,a),e=e.sibling}function Ey(e){var i=e.stateNode,a=e.memoizedProps;try{for(var o=e.type,f=i.attributes;f.length;)i.removeAttributeNode(f[0]);vt(i,o,a),i[pt]=e,i[Ut]=a}catch(h){De(e,e.return,h)}}var Pn=!1,it=!1,zf=!1,xy=typeof WeakSet=="function"?WeakSet:Set,ht=null;function Dw(e,i){if(e=e.containerInfo,Jf=Vo,e=Up(e),Sc(e)){if("selectionStart"in e)var a={start:e.selectionStart,end:e.selectionEnd};else e:{a=(a=e.ownerDocument)&&a.defaultView||window;var o=a.getSelection&&a.getSelection();if(o&&o.rangeCount!==0){a=o.anchorNode;var f=o.anchorOffset,h=o.focusNode;o=o.focusOffset;try{a.nodeType,h.nodeType}catch{a=null;break e}var v=0,_=-1,T=-1,D=0,H=0,q=e,k=null;t:for(;;){for(var U;q!==a||f!==0&&q.nodeType!==3||(_=v+f),q!==h||o!==0&&q.nodeType!==3||(T=v+o),q.nodeType===3&&(v+=q.nodeValue.length),(U=q.firstChild)!==null;)k=q,q=U;for(;;){if(q===e)break t;if(k===a&&++D===f&&(_=v),k===h&&++H===o&&(T=v),(U=q.nextSibling)!==null)break;q=k,k=q.parentNode}q=U}a=_===-1||T===-1?null:{start:_,end:T}}else a=null}a=a||{start:0,end:0}}else a=null;for(Qf={focusedElem:e,selectionRange:a},Vo=!1,ht=i;ht!==null;)if(i=ht,e=i.child,(i.subtreeFlags&1028)!==0&&e!==null)e.return=i,ht=e;else for(;ht!==null;){switch(i=ht,h=i.alternate,e=i.flags,i.tag){case 0:if((e&4)!==0&&(e=i.updateQueue,e=e!==null?e.events:null,e!==null))for(a=0;a title"))),vt(h,o,a),h[pt]=e,ft(h),o=h;break e;case"link":var v=Ag("link","href",f).get(o+(a.href||""));if(v){for(var _=0;_Ue&&(v=Ue,Ue=fe,fe=v);var C=Np(_,fe),z=Np(_,Ue);if(C&&z&&(U.rangeCount!==1||U.anchorNode!==C.node||U.anchorOffset!==C.offset||U.focusNode!==z.node||U.focusOffset!==z.offset)){var A=q.createRange();A.setStart(C.node,C.offset),U.removeAllRanges(),fe>Ue?(U.addRange(A),U.extend(z.node,z.offset)):(A.setEnd(z.node,z.offset),U.addRange(A))}}}}for(q=[],U=_;U=U.parentNode;)U.nodeType===1&&q.push({element:U,left:U.scrollLeft,top:U.scrollTop});for(typeof _.focus=="function"&&_.focus(),_=0;_a?32:a,B.T=null,a=Nf,Nf=null;var h=Mi,v=ii;if(lt=0,pr=Mi=null,ii=0,(Ae&6)!==0)throw Error(r(331));var _=Ae;if(Ae|=4,jy(h.current),Dy(h,h.current,v,a),Ae=_,Ra(0,!1),Jt&&typeof Jt.onPostCommitFiberRoot=="function")try{Jt.onPostCommitFiberRoot(Gr,h)}catch{}return!0}finally{Q.p=f,B.T=o,Wy(e,i)}}function tg(e,i,a){i=un(a,i),i=df(e.stateNode,i,2),e=xi(e,i,2),e!==null&&(Jr(e,2),Cn(e))}function De(e,i,a){if(e.tag===3)tg(e,e,a);else for(;i!==null;){if(i.tag===3){tg(i,e,a);break}else if(i.tag===1){var o=i.stateNode;if(typeof i.type.getDerivedStateFromError=="function"||typeof o.componentDidCatch=="function"&&(Ai===null||!Ai.has(o))){e=un(a,e),a=ny(2),o=xi(i,a,2),o!==null&&(iy(a,o,i,e),Jr(o,2),Cn(o));break}}i=i.return}}function Lf(e,i,a){var o=e.pingCache;if(o===null){o=e.pingCache=new jw;var f=new Set;o.set(i,f)}else f=o.get(i),f===void 0&&(f=new Set,o.set(i,f));f.has(a)||(Af=!0,f.add(a),e=Zw.bind(null,e,i,a),i.then(e,e))}function Zw(e,i,a){var o=e.pingCache;o!==null&&o.delete(i),e.pingedLanes|=e.suspendedLanes&a,e.warmLanes&=~a,Be===e&&(we&a)===a&&(Qe===4||Qe===3&&(we&62914560)===we&&300>Yt()-zo?(Ae&2)===0&&mr(e,0):Mf|=a,dr===we&&(dr=0)),Cn(e)}function ng(e,i){i===0&&(i=Xd()),e=us(e,i),e!==null&&(Jr(e,i),Cn(e))}function $w(e){var i=e.memoizedState,a=0;i!==null&&(a=i.retryLane),ng(e,a)}function qw(e,i){var a=0;switch(e.tag){case 31:case 13:var o=e.stateNode,f=e.memoizedState;f!==null&&(a=f.retryLane);break;case 19:o=e.stateNode;break;case 22:o=e.stateNode._retryCache;break;default:throw Error(r(314))}o!==null&&o.delete(i),ng(e,a)}function Iw(e,i){return Xu(e,i)}var No=null,gr=null,Hf=!1,jo=!1,Zf=!1,ki=0;function Cn(e){e!==gr&&e.next===null&&(gr===null?No=gr=e:gr=gr.next=e),jo=!0,Hf||(Hf=!0,Vw())}function Ra(e,i){if(!Zf&&jo){Zf=!0;do for(var a=!1,o=No;o!==null;){if(e!==0){var f=o.pendingLanes;if(f===0)var h=0;else{var v=o.suspendedLanes,_=o.pingedLanes;h=(1<<31-Qt(42|e)+1)-1,h&=f&~(v&~_),h=h&201326741?h&201326741|1:h?h|2:0}h!==0&&(a=!0,ag(o,h))}else h=we,h=Ll(o,o===Be?h:0,o.cancelPendingCommit!==null||o.timeoutHandle!==-1),(h&3)===0||Yr(o,h)||(a=!0,ag(o,h));o=o.next}while(a);Zf=!1}}function Kw(){ig()}function ig(){jo=Hf=!1;var e=0;ki!==0&&t_()&&(e=ki);for(var i=Yt(),a=null,o=No;o!==null;){var f=o.next,h=sg(o,i);h===0?(o.next=null,a===null?No=f:a.next=f,f===null&&(gr=a)):(a=o,(e!==0||(h&3)!==0)&&(jo=!0)),o=f}lt!==0&<!==5||Ra(e),ki!==0&&(ki=0)}function sg(e,i){for(var a=e.suspendedLanes,o=e.pingedLanes,f=e.expirationTimes,h=e.pendingLanes&-62914561;0_)break;var H=T.transferSize,q=T.initiatorType;H&&pg(q)&&(T=T.responseEnd,v+=H*(T<_?1:(_-D)/(T-D)))}if(--o,i+=8*(h+v)/(f.duration/1e3),e++,10"u"?null:document;function Og(e,i,a){var o=vr;if(o&&typeof i=="string"&&i){var f=ln(i);f='link[rel="'+e+'"][href="'+f+'"]',typeof a=="string"&&(f+='[crossorigin="'+a+'"]'),Tg.has(f)||(Tg.add(f),e={rel:e,crossOrigin:a,href:i},o.querySelector(f)===null&&(i=o.createElement("link"),vt(i,"link",e),ft(i),o.head.appendChild(i)))}}function c_(e){si.D(e),Og("dns-prefetch",e,null)}function f_(e,i){si.C(e,i),Og("preconnect",e,i)}function h_(e,i,a){si.L(e,i,a);var o=vr;if(o&&e&&i){var f='link[rel="preload"][as="'+ln(i)+'"]';i==="image"&&a&&a.imageSrcSet?(f+='[imagesrcset="'+ln(a.imageSrcSet)+'"]',typeof a.imageSizes=="string"&&(f+='[imagesizes="'+ln(a.imageSizes)+'"]')):f+='[href="'+ln(e)+'"]';var h=f;switch(i){case"style":h=Sr(e);break;case"script":h=br(e)}mn.has(h)||(e=y({rel:"preload",href:i==="image"&&a&&a.imageSrcSet?void 0:e,as:i},a),mn.set(h,e),o.querySelector(f)!==null||i==="style"&&o.querySelector(Da(h))||i==="script"&&o.querySelector(ka(h))||(i=o.createElement("link"),vt(i,"link",e),ft(i),o.head.appendChild(i)))}}function d_(e,i){si.m(e,i);var a=vr;if(a&&e){var o=i&&typeof i.as=="string"?i.as:"script",f='link[rel="modulepreload"][as="'+ln(o)+'"][href="'+ln(e)+'"]',h=f;switch(o){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":h=br(e)}if(!mn.has(h)&&(e=y({rel:"modulepreload",href:e},i),mn.set(h,e),a.querySelector(f)===null)){switch(o){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(a.querySelector(ka(h)))return}o=a.createElement("link"),vt(o,"link",e),ft(o),a.head.appendChild(o)}}}function p_(e,i,a){si.S(e,i,a);var o=vr;if(o&&e){var f=$s(o).hoistableStyles,h=Sr(e);i=i||"default";var v=f.get(h);if(!v){var _={loading:0,preload:null};if(v=o.querySelector(Da(h)))_.loading=5;else{e=y({rel:"stylesheet",href:e,"data-precedence":i},a),(a=mn.get(h))&&nh(e,a);var T=v=o.createElement("link");ft(T),vt(T,"link",e),T._p=new Promise(function(D,H){T.onload=D,T.onerror=H}),T.addEventListener("load",function(){_.loading|=1}),T.addEventListener("error",function(){_.loading|=2}),_.loading|=4,Zo(v,i,o)}v={type:"stylesheet",instance:v,count:1,state:_},f.set(h,v)}}}function m_(e,i){si.X(e,i);var a=vr;if(a&&e){var o=$s(a).hoistableScripts,f=br(e),h=o.get(f);h||(h=a.querySelector(ka(f)),h||(e=y({src:e,async:!0},i),(i=mn.get(f))&&ih(e,i),h=a.createElement("script"),ft(h),vt(h,"link",e),a.head.appendChild(h)),h={type:"script",instance:h,count:1,state:null},o.set(f,h))}}function y_(e,i){si.M(e,i);var a=vr;if(a&&e){var o=$s(a).hoistableScripts,f=br(e),h=o.get(f);h||(h=a.querySelector(ka(f)),h||(e=y({src:e,async:!0,type:"module"},i),(i=mn.get(f))&&ih(e,i),h=a.createElement("script"),ft(h),vt(h,"link",e),a.head.appendChild(h)),h={type:"script",instance:h,count:1,state:null},o.set(f,h))}}function zg(e,i,a,o){var f=(f=ge.current)?Ho(f):null;if(!f)throw Error(r(446));switch(e){case"meta":case"title":return null;case"style":return typeof a.precedence=="string"&&typeof a.href=="string"?(i=Sr(a.href),a=$s(f).hoistableStyles,o=a.get(i),o||(o={type:"style",instance:null,count:0,state:null},a.set(i,o)),o):{type:"void",instance:null,count:0,state:null};case"link":if(a.rel==="stylesheet"&&typeof a.href=="string"&&typeof a.precedence=="string"){e=Sr(a.href);var h=$s(f).hoistableStyles,v=h.get(e);if(v||(f=f.ownerDocument||f,v={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},h.set(e,v),(h=f.querySelector(Da(e)))&&!h._p&&(v.instance=h,v.state.loading=5),mn.has(e)||(a={rel:"preload",as:"style",href:a.href,crossOrigin:a.crossOrigin,integrity:a.integrity,media:a.media,hrefLang:a.hrefLang,referrerPolicy:a.referrerPolicy},mn.set(e,a),h||g_(f,e,a,v.state))),i&&o===null)throw Error(r(528,""));return v}if(i&&o!==null)throw Error(r(529,""));return null;case"script":return i=a.async,a=a.src,typeof a=="string"&&i&&typeof i!="function"&&typeof i!="symbol"?(i=br(a),a=$s(f).hoistableScripts,o=a.get(i),o||(o={type:"script",instance:null,count:0,state:null},a.set(i,o)),o):{type:"void",instance:null,count:0,state:null};default:throw Error(r(444,e))}}function Sr(e){return'href="'+ln(e)+'"'}function Da(e){return'link[rel="stylesheet"]['+e+"]"}function Rg(e){return y({},e,{"data-precedence":e.precedence,precedence:null})}function g_(e,i,a,o){e.querySelector('link[rel="preload"][as="style"]['+i+"]")?o.loading=1:(i=e.createElement("link"),o.preload=i,i.addEventListener("load",function(){return o.loading|=1}),i.addEventListener("error",function(){return o.loading|=2}),vt(i,"link",a),ft(i),e.head.appendChild(i))}function br(e){return'[src="'+ln(e)+'"]'}function ka(e){return"script[async]"+e}function Cg(e,i,a){if(i.count++,i.instance===null)switch(i.type){case"style":var o=e.querySelector('style[data-href~="'+ln(a.href)+'"]');if(o)return i.instance=o,ft(o),o;var f=y({},a,{"data-href":a.href,"data-precedence":a.precedence,href:null,precedence:null});return o=(e.ownerDocument||e).createElement("style"),ft(o),vt(o,"style",f),Zo(o,a.precedence,e),i.instance=o;case"stylesheet":f=Sr(a.href);var h=e.querySelector(Da(f));if(h)return i.state.loading|=4,i.instance=h,ft(h),h;o=Rg(a),(f=mn.get(f))&&nh(o,f),h=(e.ownerDocument||e).createElement("link"),ft(h);var v=h;return v._p=new Promise(function(_,T){v.onload=_,v.onerror=T}),vt(h,"link",o),i.state.loading|=4,Zo(h,a.precedence,e),i.instance=h;case"script":return h=br(a.src),(f=e.querySelector(ka(h)))?(i.instance=f,ft(f),f):(o=a,(f=mn.get(h))&&(o=y({},a),ih(o,f)),e=e.ownerDocument||e,f=e.createElement("script"),ft(f),vt(f,"link",o),e.head.appendChild(f),i.instance=f);case"void":return null;default:throw Error(r(443,i.type))}else i.type==="stylesheet"&&(i.state.loading&4)===0&&(o=i.instance,i.state.loading|=4,Zo(o,a.precedence,e));return i.instance}function Zo(e,i,a){for(var o=a.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),f=o.length?o[o.length-1]:null,h=f,v=0;v title"):null)}function v_(e,i,a){if(a===1||i.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof i.precedence!="string"||typeof i.href!="string"||i.href==="")break;return!0;case"link":if(typeof i.rel!="string"||typeof i.href!="string"||i.href===""||i.onLoad||i.onError)break;switch(i.rel){case"stylesheet":return e=i.disabled,typeof i.precedence=="string"&&e==null;default:return!0}case"script":if(i.async&&typeof i.async!="function"&&typeof i.async!="symbol"&&!i.onLoad&&!i.onError&&i.src&&typeof i.src=="string")return!0}return!1}function Dg(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function S_(e,i,a,o){if(a.type==="stylesheet"&&(typeof o.media!="string"||matchMedia(o.media).matches!==!1)&&(a.state.loading&4)===0){if(a.instance===null){var f=Sr(o.href),h=i.querySelector(Da(f));if(h){i=h._p,i!==null&&typeof i=="object"&&typeof i.then=="function"&&(e.count++,e=qo.bind(e),i.then(e,e)),a.state.loading|=4,a.instance=h,ft(h);return}h=i.ownerDocument||i,o=Rg(o),(f=mn.get(f))&&nh(o,f),h=h.createElement("link"),ft(h);var v=h;v._p=new Promise(function(_,T){v.onload=_,v.onerror=T}),vt(h,"link",o),a.instance=h}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(a,i),(i=a.state.preload)&&(a.state.loading&3)===0&&(e.count++,a=qo.bind(e),i.addEventListener("load",a),i.addEventListener("error",a))}}var sh=0;function b_(e,i){return e.stylesheets&&e.count===0&&Ko(e,e.stylesheets),0sh?50:800)+i);return e.unsuspend=a,function(){e.unsuspend=null,clearTimeout(o),clearTimeout(f)}}:null}function qo(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Ko(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Io=null;function Ko(e,i){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Io=new Map,i.forEach(w_,e),Io=null,qo.call(e))}function w_(e,i){if(!(i.state.loading&4)){var a=Io.get(e);if(a)var o=a.get(null);else{a=new Map,Io.set(e,a);for(var f=e.querySelectorAll("link[data-precedence],style[data-precedence]"),h=0;h"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(t){console.error(t)}}return n(),mh.exports=Z_(),mh.exports}var q_=$_();/** + * react-router v7.12.0 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var iv="popstate";function I_(n={}){function t(r,l){let{pathname:u,search:c,hash:d}=r.location;return Uh("",{pathname:u,search:c,hash:d},l.state&&l.state.usr||null,l.state&&l.state.key||"default")}function s(r,l){return typeof l=="string"?l:Pa(l)}return V_(t,s,null,n)}function Ie(n,t){if(n===!1||n===null||typeof n>"u")throw new Error(t)}function gn(n,t){if(!n){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function K_(){return Math.random().toString(36).substring(2,10)}function sv(n,t){return{usr:n.state,key:n.key,idx:t}}function Uh(n,t,s=null,r){return{pathname:typeof n=="string"?n:n.pathname,search:"",hash:"",...typeof t=="string"?Zr(t):t,state:s,key:t&&t.key||r||K_()}}function Pa({pathname:n="/",search:t="",hash:s=""}){return t&&t!=="?"&&(n+=t.charAt(0)==="?"?t:"?"+t),s&&s!=="#"&&(n+=s.charAt(0)==="#"?s:"#"+s),n}function Zr(n){let t={};if(n){let s=n.indexOf("#");s>=0&&(t.hash=n.substring(s),n=n.substring(0,s));let r=n.indexOf("?");r>=0&&(t.search=n.substring(r),n=n.substring(0,r)),n&&(t.pathname=n)}return t}function V_(n,t,s,r={}){let{window:l=document.defaultView,v5Compat:u=!1}=r,c=l.history,d="POP",p=null,m=g();m==null&&(m=0,c.replaceState({...c.state,idx:m},""));function g(){return(c.state||{idx:null}).idx}function y(){d="POP";let O=g(),M=O==null?null:O-m;m=O,p&&p({action:d,location:E.location,delta:M})}function w(O,M){d="PUSH";let L=Uh(E.location,O,M);m=g()+1;let N=sv(L,m),K=E.createHref(L);try{c.pushState(N,"",K)}catch(F){if(F instanceof DOMException&&F.name==="DataCloneError")throw F;l.location.assign(K)}u&&p&&p({action:d,location:E.location,delta:1})}function S(O,M){d="REPLACE";let L=Uh(E.location,O,M);m=g();let N=sv(L,m),K=E.createHref(L);c.replaceState(N,"",K),u&&p&&p({action:d,location:E.location,delta:0})}function b(O){return G_(O)}let E={get action(){return d},get location(){return n(l,c)},listen(O){if(p)throw new Error("A history only accepts one active listener");return l.addEventListener(iv,y),p=O,()=>{l.removeEventListener(iv,y),p=null}},createHref(O){return t(l,O)},createURL:b,encodeLocation(O){let M=b(O);return{pathname:M.pathname,search:M.search,hash:M.hash}},push:w,replace:S,go(O){return c.go(O)}};return E}function G_(n,t=!1){let s="http://localhost";typeof window<"u"&&(s=window.location.origin!=="null"?window.location.origin:window.location.href),Ie(s,"No window.location.(origin|href) available to create URL");let r=typeof n=="string"?n:Pa(n);return r=r.replace(/ $/,"%20"),!t&&r.startsWith("//")&&(r=s+r),new URL(r,s)}function E0(n,t,s="/"){return Y_(n,t,s,!1)}function Y_(n,t,s,r){let l=typeof t=="string"?Zr(t):t,u=di(l.pathname||"/",s);if(u==null)return null;let c=x0(n);J_(c);let d=null;for(let p=0;d==null&&p{let g={relativePath:m===void 0?c.path||"":m,caseSensitive:c.caseSensitive===!0,childrenIndex:d,route:c};if(g.relativePath.startsWith("/")){if(!g.relativePath.startsWith(r)&&p)return;Ie(g.relativePath.startsWith(r),`Absolute route path "${g.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),g.relativePath=g.relativePath.slice(r.length)}let y=hi([r,g.relativePath]),w=s.concat(g);c.children&&c.children.length>0&&(Ie(c.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${y}".`),x0(c.children,t,w,y,p)),!(c.path==null&&!c.index)&&t.push({path:y,score:tE(y,c.index),routesMeta:w})};return n.forEach((c,d)=>{var p;if(c.path===""||!((p=c.path)!=null&&p.includes("?")))u(c,d);else for(let m of T0(c.path))u(c,d,!0,m)}),t}function T0(n){let t=n.split("/");if(t.length===0)return[];let[s,...r]=t,l=s.endsWith("?"),u=s.replace(/\?$/,"");if(r.length===0)return l?[u,""]:[u];let c=T0(r.join("/")),d=[];return d.push(...c.map(p=>p===""?u:[u,p].join("/"))),l&&d.push(...c),d.map(p=>n.startsWith("/")&&p===""?"/":p)}function J_(n){n.sort((t,s)=>t.score!==s.score?s.score-t.score:nE(t.routesMeta.map(r=>r.childrenIndex),s.routesMeta.map(r=>r.childrenIndex)))}var Q_=/^:[\w-]+$/,X_=3,F_=2,P_=1,W_=10,eE=-2,rv=n=>n==="*";function tE(n,t){let s=n.split("/"),r=s.length;return s.some(rv)&&(r+=eE),t&&(r+=F_),s.filter(l=>!rv(l)).reduce((l,u)=>l+(Q_.test(u)?X_:u===""?P_:W_),r)}function nE(n,t){return n.length===t.length&&n.slice(0,-1).every((r,l)=>r===t[l])?n[n.length-1]-t[t.length-1]:0}function iE(n,t,s=!1){let{routesMeta:r}=n,l={},u="/",c=[];for(let d=0;d{if(g==="*"){let b=d[w]||"";c=u.slice(0,u.length-b.length).replace(/(.)\/+$/,"$1")}const S=d[w];return y&&!S?m[g]=void 0:m[g]=(S||"").replace(/%2F/g,"/"),m},{}),pathname:u,pathnameBase:c,pattern:n}}function sE(n,t=!1,s=!0){gn(n==="*"||!n.endsWith("*")||n.endsWith("/*"),`Route path "${n}" will be treated as if it were "${n.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${n.replace(/\*$/,"/*")}".`);let r=[],l="^"+n.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(c,d,p)=>(r.push({paramName:d,isOptional:p!=null}),p?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return n.endsWith("*")?(r.push({paramName:"*"}),l+=n==="*"||n==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):s?l+="\\/*$":n!==""&&n!=="/"&&(l+="(?:(?=\\/|$))"),[new RegExp(l,t?void 0:"i"),r]}function rE(n){try{return n.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return gn(!1,`The URL path "${n}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),n}}function di(n,t){if(t==="/")return n;if(!n.toLowerCase().startsWith(t.toLowerCase()))return null;let s=t.endsWith("/")?t.length-1:t.length,r=n.charAt(s);return r&&r!=="/"?null:n.slice(s)||"/"}var O0=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,aE=n=>O0.test(n);function lE(n,t="/"){let{pathname:s,search:r="",hash:l=""}=typeof n=="string"?Zr(n):n,u;if(s)if(aE(s))u=s;else{if(s.includes("//")){let c=s;s=s.replace(/\/\/+/g,"/"),gn(!1,`Pathnames cannot have embedded double slashes - normalizing ${c} -> ${s}`)}s.startsWith("/")?u=av(s.substring(1),"/"):u=av(s,t)}else u=t;return{pathname:u,search:cE(r),hash:fE(l)}}function av(n,t){let s=t.replace(/\/+$/,"").split("/");return n.split("/").forEach(l=>{l===".."?s.length>1&&s.pop():l!=="."&&s.push(l)}),s.length>1?s.join("/"):"/"}function Sh(n,t,s,r){return`Cannot include a '${n}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${s}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function oE(n){return n.filter((t,s)=>s===0||t.route.path&&t.route.path.length>0)}function cd(n){let t=oE(n);return t.map((s,r)=>r===t.length-1?s.pathname:s.pathnameBase)}function fd(n,t,s,r=!1){let l;typeof n=="string"?l=Zr(n):(l={...n},Ie(!l.pathname||!l.pathname.includes("?"),Sh("?","pathname","search",l)),Ie(!l.pathname||!l.pathname.includes("#"),Sh("#","pathname","hash",l)),Ie(!l.search||!l.search.includes("#"),Sh("#","search","hash",l)));let u=n===""||l.pathname==="",c=u?"/":l.pathname,d;if(c==null)d=s;else{let y=t.length-1;if(!r&&c.startsWith("..")){let w=c.split("/");for(;w[0]==="..";)w.shift(),y-=1;l.pathname=w.join("/")}d=y>=0?t[y]:"/"}let p=lE(l,d),m=c&&c!=="/"&&c.endsWith("/"),g=(u||c===".")&&s.endsWith("/");return!p.pathname.endsWith("/")&&(m||g)&&(p.pathname+="/"),p}var hi=n=>n.join("/").replace(/\/\/+/g,"/"),uE=n=>n.replace(/\/+$/,"").replace(/^\/*/,"/"),cE=n=>!n||n==="?"?"":n.startsWith("?")?n:"?"+n,fE=n=>!n||n==="#"?"":n.startsWith("#")?n:"#"+n,hE=class{constructor(n,t,s,r=!1){this.status=n,this.statusText=t||"",this.internal=r,s instanceof Error?(this.data=s.toString(),this.error=s):this.data=s}};function dE(n){return n!=null&&typeof n.status=="number"&&typeof n.statusText=="string"&&typeof n.internal=="boolean"&&"data"in n}function pE(n){return n.map(t=>t.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var z0=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function R0(n,t){let s=n;if(typeof s!="string"||!O0.test(s))return{absoluteURL:void 0,isExternal:!1,to:s};let r=s,l=!1;if(z0)try{let u=new URL(window.location.href),c=s.startsWith("//")?new URL(u.protocol+s):new URL(s),d=di(c.pathname,t);c.origin===u.origin&&d!=null?s=d+c.search+c.hash:l=!0}catch{gn(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:l,to:s}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var C0=["POST","PUT","PATCH","DELETE"];new Set(C0);var mE=["GET",...C0];new Set(mE);var $r=j.createContext(null);$r.displayName="DataRouter";var Bu=j.createContext(null);Bu.displayName="DataRouterState";var yE=j.createContext(!1),A0=j.createContext({isTransitioning:!1});A0.displayName="ViewTransition";var gE=j.createContext(new Map);gE.displayName="Fetchers";var vE=j.createContext(null);vE.displayName="Await";var sn=j.createContext(null);sn.displayName="Navigation";var zl=j.createContext(null);zl.displayName="Location";var Tn=j.createContext({outlet:null,matches:[],isDataRoute:!1});Tn.displayName="Route";var hd=j.createContext(null);hd.displayName="RouteError";var M0="REACT_ROUTER_ERROR",SE="REDIRECT",bE="ROUTE_ERROR_RESPONSE";function wE(n){if(n.startsWith(`${M0}:${SE}:{`))try{let t=JSON.parse(n.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function _E(n){if(n.startsWith(`${M0}:${bE}:{`))try{let t=JSON.parse(n.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new hE(t.status,t.statusText,t.data)}catch{}}function EE(n,{relative:t}={}){Ie(qr(),"useHref() may be used only in the context of a component.");let{basename:s,navigator:r}=j.useContext(sn),{hash:l,pathname:u,search:c}=Rl(n,{relative:t}),d=u;return s!=="/"&&(d=u==="/"?s:hi([s,u])),r.createHref({pathname:d,search:c,hash:l})}function qr(){return j.useContext(zl)!=null}function Wi(){return Ie(qr(),"useLocation() may be used only in the context of a component."),j.useContext(zl).location}var D0="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function k0(n){j.useContext(sn).static||j.useLayoutEffect(n)}function N0(){let{isDataRoute:n}=j.useContext(Tn);return n?BE():xE()}function xE(){Ie(qr(),"useNavigate() may be used only in the context of a component.");let n=j.useContext($r),{basename:t,navigator:s}=j.useContext(sn),{matches:r}=j.useContext(Tn),{pathname:l}=Wi(),u=JSON.stringify(cd(r)),c=j.useRef(!1);return k0(()=>{c.current=!0}),j.useCallback((p,m={})=>{if(gn(c.current,D0),!c.current)return;if(typeof p=="number"){s.go(p);return}let g=fd(p,JSON.parse(u),l,m.relative==="path");n==null&&t!=="/"&&(g.pathname=g.pathname==="/"?t:hi([t,g.pathname])),(m.replace?s.replace:s.push)(g,m.state,m)},[t,s,u,l,n])}j.createContext(null);function TE(){let{matches:n}=j.useContext(Tn),t=n[n.length-1];return t?t.params:{}}function Rl(n,{relative:t}={}){let{matches:s}=j.useContext(Tn),{pathname:r}=Wi(),l=JSON.stringify(cd(s));return j.useMemo(()=>fd(n,JSON.parse(l),r,t==="path"),[n,l,r,t])}function OE(n,t){return j0(n,t)}function j0(n,t,s,r,l){var L;Ie(qr(),"useRoutes() may be used only in the context of a component.");let{navigator:u}=j.useContext(sn),{matches:c}=j.useContext(Tn),d=c[c.length-1],p=d?d.params:{},m=d?d.pathname:"/",g=d?d.pathnameBase:"/",y=d&&d.route;{let N=y&&y.path||"";B0(m,!y||N.endsWith("*")||N.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${m}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let w=Wi(),S;if(t){let N=typeof t=="string"?Zr(t):t;Ie(g==="/"||((L=N.pathname)==null?void 0:L.startsWith(g)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${g}" but pathname "${N.pathname}" was given in the \`location\` prop.`),S=N}else S=w;let b=S.pathname||"/",E=b;if(g!=="/"){let N=g.replace(/^\//,"").split("/");E="/"+b.replace(/^\//,"").split("/").slice(N.length).join("/")}let O=E0(n,{pathname:E});gn(y||O!=null,`No routes matched location "${S.pathname}${S.search}${S.hash}" `),gn(O==null||O[O.length-1].route.element!==void 0||O[O.length-1].route.Component!==void 0||O[O.length-1].route.lazy!==void 0,`Matched leaf route at location "${S.pathname}${S.search}${S.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let M=ME(O&&O.map(N=>Object.assign({},N,{params:Object.assign({},p,N.params),pathname:hi([g,u.encodeLocation?u.encodeLocation(N.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:N.pathname]),pathnameBase:N.pathnameBase==="/"?g:hi([g,u.encodeLocation?u.encodeLocation(N.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:N.pathnameBase])})),c,s,r,l);return t&&M?j.createElement(zl.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...S},navigationType:"POP"}},M):M}function zE(){let n=UE(),t=dE(n)?`${n.status} ${n.statusText}`:n instanceof Error?n.message:JSON.stringify(n),s=n instanceof Error?n.stack:null,r="rgba(200,200,200, 0.5)",l={padding:"0.5rem",backgroundColor:r},u={padding:"2px 4px",backgroundColor:r},c=null;return console.error("Error handled by React Router default ErrorBoundary:",n),c=j.createElement(j.Fragment,null,j.createElement("p",null,"💿 Hey developer 👋"),j.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",j.createElement("code",{style:u},"ErrorBoundary")," or"," ",j.createElement("code",{style:u},"errorElement")," prop on your route.")),j.createElement(j.Fragment,null,j.createElement("h2",null,"Unexpected Application Error!"),j.createElement("h3",{style:{fontStyle:"italic"}},t),s?j.createElement("pre",{style:l},s):null,c)}var RE=j.createElement(zE,null),U0=class extends j.Component{constructor(n){super(n),this.state={location:n.location,revalidation:n.revalidation,error:n.error}}static getDerivedStateFromError(n){return{error:n}}static getDerivedStateFromProps(n,t){return t.location!==n.location||t.revalidation!=="idle"&&n.revalidation==="idle"?{error:n.error,location:n.location,revalidation:n.revalidation}:{error:n.error!==void 0?n.error:t.error,location:t.location,revalidation:n.revalidation||t.revalidation}}componentDidCatch(n,t){this.props.onError?this.props.onError(n,t):console.error("React Router caught the following error during render",n)}render(){let n=this.state.error;if(this.context&&typeof n=="object"&&n&&"digest"in n&&typeof n.digest=="string"){const s=_E(n.digest);s&&(n=s)}let t=n!==void 0?j.createElement(Tn.Provider,{value:this.props.routeContext},j.createElement(hd.Provider,{value:n,children:this.props.component})):this.props.children;return this.context?j.createElement(CE,{error:n},t):t}};U0.contextType=yE;var bh=new WeakMap;function CE({children:n,error:t}){let{basename:s}=j.useContext(sn);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=wE(t.digest);if(r){let l=bh.get(t);if(l)throw l;let u=R0(r.location,s);if(z0&&!bh.get(t))if(u.isExternal||r.reloadDocument)window.location.href=u.absoluteURL||u.to;else{const c=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(u.to,{replace:r.replace}));throw bh.set(t,c),c}return j.createElement("meta",{httpEquiv:"refresh",content:`0;url=${u.absoluteURL||u.to}`})}}return n}function AE({routeContext:n,match:t,children:s}){let r=j.useContext($r);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),j.createElement(Tn.Provider,{value:n},s)}function ME(n,t=[],s=null,r=null,l=null){if(n==null){if(!s)return null;if(s.errors)n=s.matches;else if(t.length===0&&!s.initialized&&s.matches.length>0)n=s.matches;else return null}let u=n,c=s==null?void 0:s.errors;if(c!=null){let g=u.findIndex(y=>y.route.id&&(c==null?void 0:c[y.route.id])!==void 0);Ie(g>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(c).join(",")}`),u=u.slice(0,Math.min(u.length,g+1))}let d=!1,p=-1;if(s)for(let g=0;g=0?u=u.slice(0,p+1):u=[u[0]];break}}}let m=s&&r?(g,y)=>{var w,S;r(g,{location:s.location,params:((S=(w=s.matches)==null?void 0:w[0])==null?void 0:S.params)??{},unstable_pattern:pE(s.matches),errorInfo:y})}:void 0;return u.reduceRight((g,y,w)=>{let S,b=!1,E=null,O=null;s&&(S=c&&y.route.id?c[y.route.id]:void 0,E=y.route.errorElement||RE,d&&(p<0&&w===0?(B0("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),b=!0,O=null):p===w&&(b=!0,O=y.route.hydrateFallbackElement||null)));let M=t.concat(u.slice(0,w+1)),L=()=>{let N;return S?N=E:b?N=O:y.route.Component?N=j.createElement(y.route.Component,null):y.route.element?N=y.route.element:N=g,j.createElement(AE,{match:y,routeContext:{outlet:g,matches:M,isDataRoute:s!=null},children:N})};return s&&(y.route.ErrorBoundary||y.route.errorElement||w===0)?j.createElement(U0,{location:s.location,revalidation:s.revalidation,component:E,error:S,children:L(),routeContext:{outlet:null,matches:M,isDataRoute:!0},onError:m}):L()},null)}function dd(n){return`${n} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function DE(n){let t=j.useContext($r);return Ie(t,dd(n)),t}function kE(n){let t=j.useContext(Bu);return Ie(t,dd(n)),t}function NE(n){let t=j.useContext(Tn);return Ie(t,dd(n)),t}function pd(n){let t=NE(n),s=t.matches[t.matches.length-1];return Ie(s.route.id,`${n} can only be used on routes that contain a unique "id"`),s.route.id}function jE(){return pd("useRouteId")}function UE(){var r;let n=j.useContext(hd),t=kE("useRouteError"),s=pd("useRouteError");return n!==void 0?n:(r=t.errors)==null?void 0:r[s]}function BE(){let{router:n}=DE("useNavigate"),t=pd("useNavigate"),s=j.useRef(!1);return k0(()=>{s.current=!0}),j.useCallback(async(l,u={})=>{gn(s.current,D0),s.current&&(typeof l=="number"?await n.navigate(l):await n.navigate(l,{fromRouteId:t,...u}))},[n,t])}var lv={};function B0(n,t,s){!t&&!lv[n]&&(lv[n]=!0,gn(!1,s))}j.memo(LE);function LE({routes:n,future:t,state:s,onError:r}){return j0(n,void 0,s,r,t)}function L0({to:n,replace:t,state:s,relative:r}){Ie(qr()," may be used only in the context of a component.");let{static:l}=j.useContext(sn);gn(!l," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:u}=j.useContext(Tn),{pathname:c}=Wi(),d=N0(),p=fd(n,cd(u),c,r==="path"),m=JSON.stringify(p);return j.useEffect(()=>{d(JSON.parse(m),{replace:t,state:s,relative:r})},[d,m,r,t,s]),null}function lu(n){Ie(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function HE({basename:n="/",children:t=null,location:s,navigationType:r="POP",navigator:l,static:u=!1,unstable_useTransitions:c}){Ie(!qr(),"You cannot render a inside another . You should never have more than one in your app.");let d=n.replace(/^\/*/,"/"),p=j.useMemo(()=>({basename:d,navigator:l,static:u,unstable_useTransitions:c,future:{}}),[d,l,u,c]);typeof s=="string"&&(s=Zr(s));let{pathname:m="/",search:g="",hash:y="",state:w=null,key:S="default"}=s,b=j.useMemo(()=>{let E=di(m,d);return E==null?null:{location:{pathname:E,search:g,hash:y,state:w,key:S},navigationType:r}},[d,m,g,y,w,S,r]);return gn(b!=null,` is not able to match the URL "${m}${g}${y}" because it does not start with the basename, so the won't render anything.`),b==null?null:j.createElement(sn.Provider,{value:p},j.createElement(zl.Provider,{children:t,value:b}))}function ZE({children:n,location:t}){return OE(Bh(n),t)}function Bh(n,t=[]){let s=[];return j.Children.forEach(n,(r,l)=>{if(!j.isValidElement(r))return;let u=[...t,l];if(r.type===j.Fragment){s.push.apply(s,Bh(r.props.children,u));return}Ie(r.type===lu,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),Ie(!r.props.index||!r.props.children,"An index route cannot have child routes.");let c={id:r.props.id||u.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(c.children=Bh(r.props.children,u)),s.push(c)}),s}var ou="get",uu="application/x-www-form-urlencoded";function Lu(n){return typeof HTMLElement<"u"&&n instanceof HTMLElement}function $E(n){return Lu(n)&&n.tagName.toLowerCase()==="button"}function qE(n){return Lu(n)&&n.tagName.toLowerCase()==="form"}function IE(n){return Lu(n)&&n.tagName.toLowerCase()==="input"}function KE(n){return!!(n.metaKey||n.altKey||n.ctrlKey||n.shiftKey)}function VE(n,t){return n.button===0&&(!t||t==="_self")&&!KE(n)}var Po=null;function GE(){if(Po===null)try{new FormData(document.createElement("form"),0),Po=!1}catch{Po=!0}return Po}var YE=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function wh(n){return n!=null&&!YE.has(n)?(gn(!1,`"${n}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${uu}"`),null):n}function JE(n,t){let s,r,l,u,c;if(qE(n)){let d=n.getAttribute("action");r=d?di(d,t):null,s=n.getAttribute("method")||ou,l=wh(n.getAttribute("enctype"))||uu,u=new FormData(n)}else if($E(n)||IE(n)&&(n.type==="submit"||n.type==="image")){let d=n.form;if(d==null)throw new Error('Cannot submit a + + ) + } + + if (!db) { + return null + } + + const actions = createActions(db, token) + + return ( + +
+
+

Wisp

+ +
+
+ + } /> + } /> + } /> + +
+
+
+ ) +} diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx new file mode 100644 index 0000000..438f818 --- /dev/null +++ b/web/src/components/Dashboard.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router-dom' +import { useLiveQuery } from '@tanstack/react-db' +import type { WispDB } from '../db/store' + +interface DashboardProps { + db: WispDB +} + +export function Dashboard({ db }: DashboardProps) { + const sessions = useLiveQuery((q) => + q.from({ sessions: db.collections.sessions }) + .orderBy(({ sessions }) => sessions.started_at, 'desc') + ) + + return ( +
+

Sessions

+ {sessions.data?.length === 0 ? ( +

No active sessions

+ ) : ( +
+ {sessions.data?.map((session) => ( + +
{session.repo}
+
{session.branch}
+
+ + {session.status} + + Iteration {session.iteration} +
+ + ))} +
+ )} +
+ ) +} diff --git a/web/src/components/InputPrompt.tsx b/web/src/components/InputPrompt.tsx new file mode 100644 index 0000000..dbf510f --- /dev/null +++ b/web/src/components/InputPrompt.tsx @@ -0,0 +1,83 @@ +import { useState, type FormEvent, useEffect } from 'react' +import type { WispDB } from '../db/store' +import type { Actions } from '../db/actions' +import type { InputRequest } from '../db/schema' + +interface InputPromptProps { + db: WispDB + actions: Actions + request: InputRequest +} + +export function InputPrompt({ actions, request }: InputPromptProps) { + const [value, setValue] = useState('') + + // Request notification permission and show notification when needed + useEffect(() => { + if (!request.responded && document.hidden) { + requestNotificationPermission().then(() => { + showNotification('Wisp needs input', request.question) + }) + } + }, [request.id, request.responded, request.question]) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + if (!value.trim()) return + actions.submitInput({ requestId: request.id, response: value }) + setValue('') + } + + if (request.responded) { + return ( +
+

{request.question}

+

Responded: {request.response}

+
+ ) + } + + return ( +
+ +

{request.question}

+
+ setValue(e.target.value)} + placeholder="Type your response..." + autoFocus + /> + +
+ +
+ ) +} + +async function requestNotificationPermission(): Promise { + if (!('Notification' in window)) { + return false + } + if (Notification.permission === 'granted') { + return true + } + if (Notification.permission !== 'denied') { + const permission = await Notification.requestPermission() + return permission === 'granted' + } + return false +} + +function showNotification(title: string, body: string): void { + if (Notification.permission === 'granted') { + const notification = new Notification(title, { body }) + notification.onclick = () => { + window.focus() + notification.close() + } + } +} diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx new file mode 100644 index 0000000..dabf942 --- /dev/null +++ b/web/src/components/Login.tsx @@ -0,0 +1,64 @@ +import { useState, type FormEvent } from 'react' + +interface LoginProps { + onLogin: (token: string) => void +} + +export function Login({ onLogin }: LoginProps) { + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + const res = await fetch('/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }) + + if (!res.ok) { + if (res.status === 401) { + setError('Invalid password') + } else { + setError(`Authentication failed: ${res.status}`) + } + setLoading(false) + return + } + + const data = await res.json() + onLogin(data.token) + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed') + setLoading(false) + } + } + + return ( +
+
+

Wisp

+

Remote Access

+
+ setPassword(e.target.value)} + placeholder="Password" + disabled={loading} + autoFocus + /> + + {error &&
{error}
} +
+
+
+ ) +} diff --git a/web/src/components/OutputLog.tsx b/web/src/components/OutputLog.tsx new file mode 100644 index 0000000..b953b18 --- /dev/null +++ b/web/src/components/OutputLog.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef } from 'react' +import { useLiveQuery, eq } from '@tanstack/react-db' +import type { WispDB, ClaudeEvent, SDKMessage } from '../db/store' + +interface OutputLogProps { + db: WispDB + sessionId: string +} + +export function OutputLog({ db, sessionId }: OutputLogProps) { + const bottomRef = useRef(null) + + const events = useLiveQuery((q) => + q.from({ events: db.collections.claude_events }) + .where(({ events }) => eq(events.session_id, sessionId)) + .orderBy(({ events }) => events.sequence, 'asc') + ) + + // Auto-scroll to latest output + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [events.data?.length]) + + return ( +
+

Output

+
+ {events.data?.map((e) => ( + + ))} +
+
+
+ ) +} + +// Render based on SDKMessage.type - uses types from @anthropic-ai/claude-agent-sdk +function ClaudeEventRow({ event }: { event: ClaudeEvent }) { + const msg = event.message as SDKMessage + + switch (msg.type) { + case 'assistant': + // msg.message.content is ContentBlock[] (text, tool_use, etc.) + return ( +
+ {(msg as any).message?.content?.map((block: any, i: number) => ( + + ))} +
+ ) + case 'user': + // Tool results from Claude + return ( +
+
{JSON.stringify((msg as any).message?.content, null, 2)}
+
+ ) + case 'result': + return ( +
+ {(msg as any).subtype === 'success' + ? `Done: ${(msg as any).num_turns} turns, $${(msg as any).total_cost_usd?.toFixed(2) || '?'}` + : `Error: ${(msg as any).subtype}`} +
+ ) + case 'system': + return
Session: {(msg as any).session_id}
+ default: + return null + } +} + +// Render content blocks from assistant messages +function ContentBlock({ block }: { block: any }) { + if (block.type === 'text') { + return
{block.text}
+ } + if (block.type === 'tool_use') { + return ( +
+ [{block.name}] +
{JSON.stringify(block.input, null, 2)}
+
+ ) + } + return null +} diff --git a/web/src/components/Session.tsx b/web/src/components/Session.tsx new file mode 100644 index 0000000..3aa5414 --- /dev/null +++ b/web/src/components/Session.tsx @@ -0,0 +1,79 @@ +import { useParams, Navigate } from 'react-router-dom' +import { useLiveQuery, eq } from '@tanstack/react-db' +import type { WispDB } from '../db/store' +import type { Actions } from '../db/actions' +import { TaskList } from './TaskList' +import { OutputLog } from './OutputLog' +import { InputPrompt } from './InputPrompt' + +interface SessionProps { + db: WispDB + actions: Actions +} + +export function Session({ db, actions }: SessionProps) { + const { id } = useParams<{ id: string }>() + + const sessionQuery = useLiveQuery((q) => + q.from({ sessions: db.collections.sessions }) + .where(({ sessions }) => eq(sessions.id, id!)) + .limit(1) + ) + + const inputRequests = useLiveQuery((q) => + q.from({ requests: db.collections.input_requests }) + .where(({ requests }) => eq(requests.session_id, id!)) + .where(({ requests }) => eq(requests.responded, false)) + .orderBy(({ requests }) => requests.iteration, 'desc') + .limit(1) + ) + + const session = sessionQuery.data?.[0] + const activeRequest = inputRequests.data?.[0] + + if (!id) { + return + } + + if (!session) { + return ( +
+

Loading session...

+
+ ) + } + + return ( +
+
+
+

{session.repo}

+

{session.branch}

+
+
+ + {session.status} + + Iteration {session.iteration} +
+
+ +
+ + +
+ {activeRequest && ( + + )} + +
+
+
+ ) +} diff --git a/web/src/components/TaskList.tsx b/web/src/components/TaskList.tsx new file mode 100644 index 0000000..5d22f2e --- /dev/null +++ b/web/src/components/TaskList.tsx @@ -0,0 +1,37 @@ +import { useLiveQuery, eq } from '@tanstack/react-db' +import type { WispDB } from '../db/store' + +interface TaskListProps { + db: WispDB + sessionId: string +} + +export function TaskList({ db, sessionId }: TaskListProps) { + const tasks = useLiveQuery((q) => + q.from({ tasks: db.collections.tasks }) + .where(({ tasks }) => eq(tasks.session_id, sessionId)) + .orderBy(({ tasks }) => tasks.order, 'asc') + ) + + return ( +
+

Tasks

+ {tasks.data?.length === 0 ? ( +

No tasks yet

+ ) : ( +
    + {tasks.data?.map((task) => ( +
  • + + {task.status === 'completed' && '✓'} + {task.status === 'in_progress' && '→'} + {task.status === 'pending' && '○'} + + {task.content} +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/db/actions.ts b/web/src/db/actions.ts new file mode 100644 index 0000000..6cd5ff1 --- /dev/null +++ b/web/src/db/actions.ts @@ -0,0 +1,33 @@ +import { createOptimisticAction } from '@tanstack/db' +import type { WispDB } from './store' + +export function createActions(db: WispDB, token: string) { + const submitInput = createOptimisticAction<{ requestId: string; response: string }>({ + onMutate: ({ requestId, response }) => { + // Instant optimistic update + db.collections.input_requests.update(requestId, (draft) => { + draft.responded = true + draft.response = response + }) + }, + mutationFn: async ({ requestId, response }) => { + // POST to server + const res = await fetch('/input', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ request_id: requestId, response }), + }) + if (!res.ok) { + throw new Error(`Failed to submit input: ${res.status}`) + } + // No refetch needed - server sends confirmed state via stream + }, + }) + + return { submitInput } +} + +export type Actions = ReturnType diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts new file mode 100644 index 0000000..cc5ca7d --- /dev/null +++ b/web/src/db/schema.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import { createStateSchema } from '@durable-streams/state' + +export const sessionSchema = z.object({ + id: z.string(), + repo: z.string(), + branch: z.string(), + spec: z.string(), + status: z.enum(['running', 'needs_input', 'blocked', 'done']), + iteration: z.number(), + started_at: z.string(), +}) + +export const taskSchema = z.object({ + id: z.string(), + session_id: z.string(), + order: z.number(), + content: z.string(), + status: z.enum(['pending', 'in_progress', 'completed']), +}) + +// SDKMessage schema - passthrough since types come from @anthropic-ai/claude-agent-sdk +// The SDK types (SDKAssistantMessage, SDKResultMessage, etc.) are complex unions. +// Use z.any() and rely on TypeScript for type safety at boundaries. +export const claudeEventSchema = z.object({ + id: z.string(), + session_id: z.string(), + iteration: z.number(), + sequence: z.number(), + message: z.any(), // SDKMessage from @anthropic-ai/claude-agent-sdk + timestamp: z.string(), +}) + +export const inputRequestSchema = z.object({ + id: z.string(), + session_id: z.string(), + iteration: z.number(), + question: z.string(), + responded: z.boolean(), + response: z.string().nullable(), +}) + +export const stateSchema = createStateSchema({ + sessions: { schema: sessionSchema, type: 'session', primaryKey: 'id' }, + tasks: { schema: taskSchema, type: 'task', primaryKey: 'id' }, + claude_events: { schema: claudeEventSchema, type: 'claude_event', primaryKey: 'id' }, + input_requests: { schema: inputRequestSchema, type: 'input_request', primaryKey: 'id' }, +}) + +export type Session = z.infer +export type Task = z.infer +export type ClaudeEvent = z.infer +export type InputRequest = z.infer diff --git a/web/src/db/store.ts b/web/src/db/store.ts new file mode 100644 index 0000000..49f0228 --- /dev/null +++ b/web/src/db/store.ts @@ -0,0 +1,25 @@ +import { createStreamDB } from '@durable-streams/state' +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import { stateSchema } from './schema' + +export type { SDKMessage } + +export function createDb(token: string) { + return createStreamDB({ + streamOptions: { + url: '/stream', + headers: { Authorization: `Bearer ${token}` }, + }, + state: stateSchema, + }) +} + +export type WispDB = ReturnType +export type ClaudeEvent = { + id: string + session_id: string + iteration: number + sequence: number + message: SDKMessage + timestamp: string +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3a58b71 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './styles/main.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/styles/main.css b/web/src/styles/main.css new file mode 100644 index 0000000..4ddd121 --- /dev/null +++ b/web/src/styles/main.css @@ -0,0 +1,513 @@ +/* Reset and base styles */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --color-bg: #f5f5f5; + --color-surface: #ffffff; + --color-text: #333333; + --color-text-muted: #666666; + --color-border: #e0e0e0; + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-success: #16a34a; + --color-warning: #ca8a04; + --color-error: #dc2626; + --radius: 8px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + background: var(--color-bg); + color: var(--color-text); +} + +/* App layout */ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow); +} + +.app-header h1 { + margin: 0; + font-size: 1.5rem; +} + +.logout-btn { + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: transparent; + cursor: pointer; +} + +.logout-btn:hover { + background: var(--color-bg); +} + +main { + flex: 1; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +/* Login */ +.login { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: var(--color-bg); +} + +.login-card { + background: var(--color-surface); + padding: 2rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; + width: 100%; + max-width: 320px; +} + +.login-card h1 { + margin: 0 0 0.5rem; +} + +.login-card p { + margin: 0 0 1.5rem; + color: var(--color-text-muted); +} + +.login-card form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-card input { + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 1rem; +} + +.login-card button { + padding: 0.75rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + font-size: 1rem; + cursor: pointer; +} + +.login-card button:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.login-card button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.error-message { + color: var(--color-error); + font-size: 0.875rem; +} + +/* Loading and error states */ +.loading, +.error { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; +} + +.error button { + padding: 0.5rem 1rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + cursor: pointer; +} + +/* Dashboard */ +.dashboard h2 { + margin: 0 0 1.5rem; +} + +.no-sessions { + color: var(--color-text-muted); +} + +.session-list { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.session-card { + display: block; + padding: 1rem; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + text-decoration: none; + color: inherit; + border-left: 4px solid transparent; + transition: transform 0.1s, box-shadow 0.1s; +} + +.session-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.session-card.status-running { + border-left-color: var(--color-primary); +} + +.session-card.status-needs_input { + border-left-color: var(--color-warning); +} + +.session-card.status-blocked { + border-left-color: var(--color-error); +} + +.session-card.status-done { + border-left-color: var(--color-success); +} + +.session-repo { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.session-branch { + color: var(--color-text-muted); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.session-meta { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Status badges */ +.status-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.status-badge.running { + background: #dbeafe; + color: var(--color-primary); +} + +.status-badge.needs_input { + background: #fef3c7; + color: var(--color-warning); +} + +.status-badge.blocked { + background: #fee2e2; + color: var(--color-error); +} + +.status-badge.done { + background: #dcfce7; + color: var(--color-success); +} + +.iteration { + font-size: 0.875rem; + color: var(--color-text-muted); +} + +/* Session view */ +.session-loading { + text-align: center; + padding: 2rem; +} + +.session-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.session-info h2 { + margin: 0 0 0.25rem; +} + +.session-info .branch { + margin: 0; + color: var(--color-text-muted); +} + +.session-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} + +.session-content { + display: grid; + grid-template-columns: 300px 1fr; + gap: 1.5rem; +} + +@media (max-width: 768px) { + .session-content { + grid-template-columns: 1fr; + } +} + +/* Task list */ +.session-sidebar { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; +} + +.task-list h3 { + margin: 0 0 1rem; +} + +.task-list ul { + list-style: none; + margin: 0; + padding: 0; +} + +.task-list .task { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--color-border); +} + +.task-list .task:last-child { + border-bottom: none; +} + +.task-status-icon { + flex-shrink: 0; + width: 1.25rem; + text-align: center; +} + +.task.status-completed .task-status-icon { + color: var(--color-success); +} + +.task.status-in_progress .task-status-icon { + color: var(--color-primary); +} + +.task.status-pending .task-status-icon { + color: var(--color-text-muted); +} + +.task-content { + flex: 1; + word-break: break-word; +} + +.task.status-completed .task-content { + color: var(--color-text-muted); +} + +.no-tasks { + color: var(--color-text-muted); + margin: 0; +} + +/* Output log */ +.session-main { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.output-log { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; + flex: 1; + min-height: 400px; + display: flex; + flex-direction: column; +} + +.output-log h3 { + margin: 0 0 1rem; +} + +.output-content { + flex: 1; + overflow-y: auto; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 0.875rem; + background: #1a1a1a; + color: #e0e0e0; + padding: 1rem; + border-radius: 4px; + max-height: 500px; +} + +.event { + margin-bottom: 0.5rem; + padding: 0.5rem; + border-radius: 4px; +} + +.event.assistant { + background: rgba(37, 99, 235, 0.1); +} + +.event.tool-result { + background: rgba(0, 0, 0, 0.2); +} + +.event.tool-result pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + font-size: 0.75rem; + max-height: 200px; + overflow-y: auto; +} + +.event.result { + background: rgba(22, 163, 74, 0.2); + color: #4ade80; +} + +.event.system { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.content-text { + white-space: pre-wrap; + word-break: break-word; +} + +.content-tool-use { + background: rgba(0, 0, 0, 0.2); + padding: 0.5rem; + border-radius: 4px; + margin: 0.25rem 0; +} + +.content-tool-use summary { + cursor: pointer; + color: #93c5fd; +} + +.content-tool-use pre { + margin: 0.5rem 0 0; + white-space: pre-wrap; + word-break: break-all; + font-size: 0.75rem; + max-height: 200px; + overflow-y: auto; +} + +/* Input prompt */ +.input-prompt { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem; + border: 2px solid var(--color-warning); +} + +.input-prompt .question { + margin: 0 0 1rem; + font-weight: 600; +} + +.input-prompt .input-row { + display: flex; + gap: 0.5rem; +} + +.input-prompt input { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 1rem; +} + +.input-prompt button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + font-size: 1rem; + cursor: pointer; +} + +.input-prompt button:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.input-prompt button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input-prompt.responded { + border-color: var(--color-success); + background: #dcfce7; +} + +.input-prompt.responded .response { + margin: 0; + color: var(--color-success); +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..0426f7b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..2aed95d --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + emptyDirOnBuild: false, + }, + server: { + proxy: { + '/auth': 'http://localhost:8374', + '/stream': 'http://localhost:8374', + '/input': 'http://localhost:8374', + }, + }, +}) From 66b0bc9432c4412e989b4c4e67a1e66ce0fdb4b0 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:58:03 +0000 Subject: [PATCH 10/19] feat(web): implement disconnection handling for web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detection and UI for stream disconnection: - Create Disconnected component with reconnect button - Track connection state in App (disconnected/connecting/connected/error) - Use onError callback in createDb to detect stream errors - Show disconnected page when connection is lost after being connected - Add CSS styling for disconnected state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...{index-3svPJstS.css => index-CNDrCL7h.css} | 2 +- .../{index-BdGL3ZQM.js => index-twtfKIHy.js} | 2 +- web/dist/index.html | 4 +- web/src/App.tsx | 54 ++++++++++++++---- web/src/components/Disconnected.tsx | 24 ++++++++ web/src/db/store.ts | 16 +++++- web/src/styles/main.css | 57 +++++++++++++++++++ 7 files changed, 144 insertions(+), 15 deletions(-) rename web/dist/assets/{index-3svPJstS.css => index-CNDrCL7h.css} (89%) rename web/dist/assets/{index-BdGL3ZQM.js => index-twtfKIHy.js} (97%) create mode 100644 web/src/components/Disconnected.tsx diff --git a/web/dist/assets/index-3svPJstS.css b/web/dist/assets/index-CNDrCL7h.css similarity index 89% rename from web/dist/assets/index-3svPJstS.css rename to web/dist/assets/index-CNDrCL7h.css index 2c221e4..616cc24 100644 --- a/web/dist/assets/index-3svPJstS.css +++ b/web/dist/assets/index-CNDrCL7h.css @@ -1 +1 @@ -*,*:before,*:after{box-sizing:border-box}:root{--color-bg: #f5f5f5;--color-surface: #ffffff;--color-text: #333333;--color-text-muted: #666666;--color-border: #e0e0e0;--color-primary: #2563eb;--color-primary-hover: #1d4ed8;--color-success: #16a34a;--color-warning: #ca8a04;--color-error: #dc2626;--radius: 8px;--shadow: 0 1px 3px rgba(0, 0, 0, .1)}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5;background:var(--color-bg);color:var(--color-text)}.app{min-height:100vh;display:flex;flex-direction:column}.app-header{display:flex;justify-content:space-between;align-items:center;padding:1rem 2rem;background:var(--color-surface);border-bottom:1px solid var(--color-border);box-shadow:var(--shadow)}.app-header h1{margin:0;font-size:1.5rem}.logout-btn{padding:.5rem 1rem;border:1px solid var(--color-border);border-radius:var(--radius);background:transparent;cursor:pointer}.logout-btn:hover{background:var(--color-bg)}main{flex:1;padding:2rem;max-width:1400px;margin:0 auto;width:100%}.login{min-height:100vh;display:flex;justify-content:center;align-items:center;background:var(--color-bg)}.login-card{background:var(--color-surface);padding:2rem;border-radius:var(--radius);box-shadow:var(--shadow);text-align:center;width:100%;max-width:320px}.login-card h1{margin:0 0 .5rem}.login-card p{margin:0 0 1.5rem;color:var(--color-text-muted)}.login-card form{display:flex;flex-direction:column;gap:1rem}.login-card input{padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.login-card button{padding:.75rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.login-card button:hover:not(:disabled){background:var(--color-primary-hover)}.login-card button:disabled{opacity:.5;cursor:not-allowed}.error-message{color:var(--color-error);font-size:.875rem}.loading,.error{min-height:100vh;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:1rem}.error button{padding:.5rem 1rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;cursor:pointer}.dashboard h2{margin:0 0 1.5rem}.no-sessions{color:var(--color-text-muted)}.session-list{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}.session-card{display:block;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);text-decoration:none;color:inherit;border-left:4px solid transparent;transition:transform .1s,box-shadow .1s}.session-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px #00000026}.session-card.status-running{border-left-color:var(--color-primary)}.session-card.status-needs_input{border-left-color:var(--color-warning)}.session-card.status-blocked{border-left-color:var(--color-error)}.session-card.status-done{border-left-color:var(--color-success)}.session-repo{font-weight:600;margin-bottom:.25rem}.session-branch{color:var(--color-text-muted);font-size:.875rem;margin-bottom:.5rem}.session-meta{display:flex;justify-content:space-between;align-items:center}.status-badge{display:inline-block;padding:.25rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;text-transform:uppercase}.status-badge.running{background:#dbeafe;color:var(--color-primary)}.status-badge.needs_input{background:#fef3c7;color:var(--color-warning)}.status-badge.blocked{background:#fee2e2;color:var(--color-error)}.status-badge.done{background:#dcfce7;color:var(--color-success)}.iteration{font-size:.875rem;color:var(--color-text-muted)}.session-loading{text-align:center;padding:2rem}.session-header{display:flex;justify-content:space-between;align-items:flex-start;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:1.5rem}.session-info h2{margin:0 0 .25rem}.session-info .branch{margin:0;color:var(--color-text-muted)}.session-status{display:flex;flex-direction:column;align-items:flex-end;gap:.5rem}.session-content{display:grid;grid-template-columns:300px 1fr;gap:1.5rem}@media(max-width:768px){.session-content{grid-template-columns:1fr}}.session-sidebar{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem}.task-list h3{margin:0 0 1rem}.task-list ul{list-style:none;margin:0;padding:0}.task-list .task{display:flex;align-items:flex-start;gap:.5rem;padding:.5rem 0;border-bottom:1px solid var(--color-border)}.task-list .task:last-child{border-bottom:none}.task-status-icon{flex-shrink:0;width:1.25rem;text-align:center}.task.status-completed .task-status-icon{color:var(--color-success)}.task.status-in_progress .task-status-icon{color:var(--color-primary)}.task.status-pending .task-status-icon{color:var(--color-text-muted)}.task-content{flex:1;word-break:break-word}.task.status-completed .task-content{color:var(--color-text-muted)}.no-tasks{color:var(--color-text-muted);margin:0}.session-main{display:flex;flex-direction:column;gap:1rem}.output-log{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;flex:1;min-height:400px;display:flex;flex-direction:column}.output-log h3{margin:0 0 1rem}.output-content{flex:1;overflow-y:auto;font-family:SF Mono,Monaco,Consolas,monospace;font-size:.875rem;background:#1a1a1a;color:#e0e0e0;padding:1rem;border-radius:4px;max-height:500px}.event{margin-bottom:.5rem;padding:.5rem;border-radius:4px}.event.assistant{background:#2563eb1a}.event.tool-result{background:#0003}.event.tool-result pre{margin:0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.event.result{background:#16a34a33;color:#4ade80}.event.system{color:var(--color-text-muted);font-size:.75rem}.content-text{white-space:pre-wrap;word-break:break-word}.content-tool-use{background:#0003;padding:.5rem;border-radius:4px;margin:.25rem 0}.content-tool-use summary{cursor:pointer;color:#93c5fd}.content-tool-use pre{margin:.5rem 0 0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.input-prompt{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;border:2px solid var(--color-warning)}.input-prompt .question{margin:0 0 1rem;font-weight:600}.input-prompt .input-row{display:flex;gap:.5rem}.input-prompt input{flex:1;padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.input-prompt button{padding:.75rem 1.5rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.input-prompt button:hover:not(:disabled){background:var(--color-primary-hover)}.input-prompt button:disabled{opacity:.5;cursor:not-allowed}.input-prompt.responded{border-color:var(--color-success);background:#dcfce7}.input-prompt.responded .response{margin:0;color:var(--color-success)} +*,*:before,*:after{box-sizing:border-box}:root{--color-bg: #f5f5f5;--color-surface: #ffffff;--color-text: #333333;--color-text-muted: #666666;--color-border: #e0e0e0;--color-primary: #2563eb;--color-primary-hover: #1d4ed8;--color-success: #16a34a;--color-warning: #ca8a04;--color-error: #dc2626;--radius: 8px;--shadow: 0 1px 3px rgba(0, 0, 0, .1)}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.5;background:var(--color-bg);color:var(--color-text)}.app{min-height:100vh;display:flex;flex-direction:column}.app-header{display:flex;justify-content:space-between;align-items:center;padding:1rem 2rem;background:var(--color-surface);border-bottom:1px solid var(--color-border);box-shadow:var(--shadow)}.app-header h1{margin:0;font-size:1.5rem}.logout-btn{padding:.5rem 1rem;border:1px solid var(--color-border);border-radius:var(--radius);background:transparent;cursor:pointer}.logout-btn:hover{background:var(--color-bg)}main{flex:1;padding:2rem;max-width:1400px;margin:0 auto;width:100%}.login{min-height:100vh;display:flex;justify-content:center;align-items:center;background:var(--color-bg)}.login-card{background:var(--color-surface);padding:2rem;border-radius:var(--radius);box-shadow:var(--shadow);text-align:center;width:100%;max-width:320px}.login-card h1{margin:0 0 .5rem}.login-card p{margin:0 0 1.5rem;color:var(--color-text-muted)}.login-card form{display:flex;flex-direction:column;gap:1rem}.login-card input{padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.login-card button{padding:.75rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.login-card button:hover:not(:disabled){background:var(--color-primary-hover)}.login-card button:disabled{opacity:.5;cursor:not-allowed}.error-message{color:var(--color-error);font-size:.875rem}.loading,.error{min-height:100vh;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:1rem}.error button{padding:.5rem 1rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;cursor:pointer}.dashboard h2{margin:0 0 1.5rem}.no-sessions{color:var(--color-text-muted)}.session-list{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}.session-card{display:block;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);text-decoration:none;color:inherit;border-left:4px solid transparent;transition:transform .1s,box-shadow .1s}.session-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px #00000026}.session-card.status-running{border-left-color:var(--color-primary)}.session-card.status-needs_input{border-left-color:var(--color-warning)}.session-card.status-blocked{border-left-color:var(--color-error)}.session-card.status-done{border-left-color:var(--color-success)}.session-repo{font-weight:600;margin-bottom:.25rem}.session-branch{color:var(--color-text-muted);font-size:.875rem;margin-bottom:.5rem}.session-meta{display:flex;justify-content:space-between;align-items:center}.status-badge{display:inline-block;padding:.25rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600;text-transform:uppercase}.status-badge.running{background:#dbeafe;color:var(--color-primary)}.status-badge.needs_input{background:#fef3c7;color:var(--color-warning)}.status-badge.blocked{background:#fee2e2;color:var(--color-error)}.status-badge.done{background:#dcfce7;color:var(--color-success)}.iteration{font-size:.875rem;color:var(--color-text-muted)}.session-loading{text-align:center;padding:2rem}.session-header{display:flex;justify-content:space-between;align-items:flex-start;padding:1rem;background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:1.5rem}.session-info h2{margin:0 0 .25rem}.session-info .branch{margin:0;color:var(--color-text-muted)}.session-status{display:flex;flex-direction:column;align-items:flex-end;gap:.5rem}.session-content{display:grid;grid-template-columns:300px 1fr;gap:1.5rem}@media(max-width:768px){.session-content{grid-template-columns:1fr}}.session-sidebar{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem}.task-list h3{margin:0 0 1rem}.task-list ul{list-style:none;margin:0;padding:0}.task-list .task{display:flex;align-items:flex-start;gap:.5rem;padding:.5rem 0;border-bottom:1px solid var(--color-border)}.task-list .task:last-child{border-bottom:none}.task-status-icon{flex-shrink:0;width:1.25rem;text-align:center}.task.status-completed .task-status-icon{color:var(--color-success)}.task.status-in_progress .task-status-icon{color:var(--color-primary)}.task.status-pending .task-status-icon{color:var(--color-text-muted)}.task-content{flex:1;word-break:break-word}.task.status-completed .task-content{color:var(--color-text-muted)}.no-tasks{color:var(--color-text-muted);margin:0}.session-main{display:flex;flex-direction:column;gap:1rem}.output-log{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;flex:1;min-height:400px;display:flex;flex-direction:column}.output-log h3{margin:0 0 1rem}.output-content{flex:1;overflow-y:auto;font-family:SF Mono,Monaco,Consolas,monospace;font-size:.875rem;background:#1a1a1a;color:#e0e0e0;padding:1rem;border-radius:4px;max-height:500px}.event{margin-bottom:.5rem;padding:.5rem;border-radius:4px}.event.assistant{background:#2563eb1a}.event.tool-result{background:#0003}.event.tool-result pre{margin:0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.event.result{background:#16a34a33;color:#4ade80}.event.system{color:var(--color-text-muted);font-size:.75rem}.content-text{white-space:pre-wrap;word-break:break-word}.content-tool-use{background:#0003;padding:.5rem;border-radius:4px;margin:.25rem 0}.content-tool-use summary{cursor:pointer;color:#93c5fd}.content-tool-use pre{margin:.5rem 0 0;white-space:pre-wrap;word-break:break-all;font-size:.75rem;max-height:200px;overflow-y:auto}.input-prompt{background:var(--color-surface);border-radius:var(--radius);box-shadow:var(--shadow);padding:1rem;border:2px solid var(--color-warning)}.input-prompt .question{margin:0 0 1rem;font-weight:600}.input-prompt .input-row{display:flex;gap:.5rem}.input-prompt input{flex:1;padding:.75rem;border:1px solid var(--color-border);border-radius:var(--radius);font-size:1rem}.input-prompt button{padding:.75rem 1.5rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer}.input-prompt button:hover:not(:disabled){background:var(--color-primary-hover)}.input-prompt button:disabled{opacity:.5;cursor:not-allowed}.input-prompt.responded{border-color:var(--color-success);background:#dcfce7}.input-prompt.responded .response{margin:0;color:var(--color-success)}.disconnected{min-height:100vh;display:flex;justify-content:center;align-items:center;background:var(--color-bg)}.disconnected-content{background:var(--color-surface);padding:3rem;border-radius:var(--radius);box-shadow:var(--shadow);text-align:center;max-width:400px;width:100%;margin:1rem}.disconnected-icon{font-size:4rem;margin-bottom:1rem;color:var(--color-warning)}.disconnected h1{margin:0 0 1rem;color:var(--color-text)}.disconnected-message{margin:0 0 .5rem;color:var(--color-text)}.disconnected-hint{margin:0 0 1.5rem;color:var(--color-text-muted);font-size:.875rem}.reconnect-btn{padding:.75rem 2rem;border:none;border-radius:var(--radius);background:var(--color-primary);color:#fff;font-size:1rem;cursor:pointer;transition:background .15s}.reconnect-btn:hover{background:var(--color-primary-hover)} diff --git a/web/dist/assets/index-BdGL3ZQM.js b/web/dist/assets/index-twtfKIHy.js similarity index 97% rename from web/dist/assets/index-BdGL3ZQM.js rename to web/dist/assets/index-twtfKIHy.js index 61bee71..07ed651 100644 --- a/web/dist/assets/index-BdGL3ZQM.js +++ b/web/dist/assets/index-twtfKIHy.js @@ -106,4 +106,4 @@ Available comparison functions: eq, gt, gte, lt, lte, and, or, not, like, ilike, `)}S.write("payload.value = newResult;"),S.write("return payload;");const L=S.compile();return(K,F)=>L(w,K,F)};let u;const c=Au,d=!DS.jitless,m=d&&RR.value,g=t.catchall;let y;n._zod.parse=(w,S)=>{y??(y=r.value);const b=w.value;return c(b)?d&&m&&(S==null?void 0:S.async)===!1&&S.jitless!==!0?(u||(u=l(t.shape)),w=u(w,S),g?QS([],b,w,S,y,n):w):s(w,S):(w.issues.push({expected:"object",code:"invalid_type",input:b,inst:n}),w)}});function s0(n,t,s,r){for(const u of n)if(u.issues.length===0)return t.value=u.value,t;const l=n.filter(u=>!xr(u));return l.length===1?(t.value=l[0].value,l[0]):(t.issues.push({code:"invalid_union",input:t.value,inst:s,errors:n.map(u=>u.issues.map(c=>ks(c,r,Ds())))}),t)}const yA=$("$ZodUnion",(n,t)=>{Xe.init(n,t),ke(n._zod,"optin",()=>t.options.some(l=>l._zod.optin==="optional")?"optional":void 0),ke(n._zod,"optout",()=>t.options.some(l=>l._zod.optout==="optional")?"optional":void 0),ke(n._zod,"values",()=>{if(t.options.every(l=>l._zod.values))return new Set(t.options.flatMap(l=>Array.from(l._zod.values)))}),ke(n._zod,"pattern",()=>{if(t.options.every(l=>l._zod.pattern)){const l=t.options.map(u=>u._zod.pattern);return new RegExp(`^(${l.map(u=>Hd(u.source)).join("|")})$`)}});const s=t.options.length===1,r=t.options[0]._zod.run;n._zod.parse=(l,u)=>{if(s)return r(l,u);let c=!1;const d=[];for(const p of t.options){const m=p._zod.run({value:l.value,issues:[]},u);if(m instanceof Promise)d.push(m),c=!0;else{if(m.issues.length===0)return m;d.push(m)}}return c?Promise.all(d).then(p=>s0(p,l,n,u)):s0(d,l,n,u)}}),gA=$("$ZodIntersection",(n,t)=>{Xe.init(n,t),n._zod.parse=(s,r)=>{const l=s.value,u=t.left._zod.run({value:l,issues:[]},r),c=t.right._zod.run({value:l,issues:[]},r);return u instanceof Promise||c instanceof Promise?Promise.all([u,c]).then(([p,m])=>r0(s,p,m)):r0(s,u,c)}});function ld(n,t){if(n===t)return{valid:!0,data:n};if(n instanceof Date&&t instanceof Date&&+n==+t)return{valid:!0,data:n};if(sl(n)&&sl(t)){const s=Object.keys(t),r=Object.keys(n).filter(u=>s.indexOf(u)!==-1),l={...n,...t};for(const u of r){const c=ld(n[u],t[u]);if(!c.valid)return{valid:!1,mergeErrorPath:[u,...c.mergeErrorPath]};l[u]=c.data}return{valid:!0,data:l}}if(Array.isArray(n)&&Array.isArray(t)){if(n.length!==t.length)return{valid:!1,mergeErrorPath:[]};const s=[];for(let r=0;rd.l&&d.r).map(([d])=>d);if(u.length&&l&&n.issues.push({...l,keys:u}),xr(n))return n;const c=ld(t.value,s.value);if(!c.valid)throw new Error(`Unmergable intersection. Error path: ${JSON.stringify(c.mergeErrorPath)}`);return n.value=c.data,n}const vA=$("$ZodEnum",(n,t)=>{Xe.init(n,t);const s=kS(t.entries),r=new Set(s);n._zod.values=r,n._zod.pattern=new RegExp(`^(${s.filter(l=>CR.has(typeof l)).map(l=>typeof l=="string"?qu(l):l.toString()).join("|")})$`),n._zod.parse=(l,u)=>{const c=l.value;return r.has(c)||l.issues.push({code:"invalid_value",values:s,input:c,inst:n}),l}}),SA=$("$ZodTransform",(n,t)=>{Xe.init(n,t),n._zod.parse=(s,r)=>{if(r.direction==="backward")throw new MS(n.constructor.name);const l=t.transform(s.value,s);if(r.async)return(l instanceof Promise?l:Promise.resolve(l)).then(c=>(s.value=c,s));if(l instanceof Promise)throw new Or;return s.value=l,s}});function a0(n,t){return n.issues.length&&t===void 0?{issues:[],value:void 0}:n}const XS=$("$ZodOptional",(n,t)=>{Xe.init(n,t),n._zod.optin="optional",n._zod.optout="optional",ke(n._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,void 0]):void 0),ke(n._zod,"pattern",()=>{const s=t.innerType._zod.pattern;return s?new RegExp(`^(${Hd(s.source)})?$`):void 0}),n._zod.parse=(s,r)=>{if(t.innerType._zod.optin==="optional"){const l=t.innerType._zod.run(s,r);return l instanceof Promise?l.then(u=>a0(u,s.value)):a0(l,s.value)}return s.value===void 0?s:t.innerType._zod.run(s,r)}}),bA=$("$ZodExactOptional",(n,t)=>{XS.init(n,t),ke(n._zod,"values",()=>t.innerType._zod.values),ke(n._zod,"pattern",()=>t.innerType._zod.pattern),n._zod.parse=(s,r)=>t.innerType._zod.run(s,r)}),wA=$("$ZodNullable",(n,t)=>{Xe.init(n,t),ke(n._zod,"optin",()=>t.innerType._zod.optin),ke(n._zod,"optout",()=>t.innerType._zod.optout),ke(n._zod,"pattern",()=>{const s=t.innerType._zod.pattern;return s?new RegExp(`^(${Hd(s.source)}|null)$`):void 0}),ke(n._zod,"values",()=>t.innerType._zod.values?new Set([...t.innerType._zod.values,null]):void 0),n._zod.parse=(s,r)=>s.value===null?s:t.innerType._zod.run(s,r)}),_A=$("$ZodDefault",(n,t)=>{Xe.init(n,t),n._zod.optin="optional",ke(n._zod,"values",()=>t.innerType._zod.values),n._zod.parse=(s,r)=>{if(r.direction==="backward")return t.innerType._zod.run(s,r);if(s.value===void 0)return s.value=t.defaultValue,s;const l=t.innerType._zod.run(s,r);return l instanceof Promise?l.then(u=>l0(u,t)):l0(l,t)}});function l0(n,t){return n.value===void 0&&(n.value=t.defaultValue),n}const EA=$("$ZodPrefault",(n,t)=>{Xe.init(n,t),n._zod.optin="optional",ke(n._zod,"values",()=>t.innerType._zod.values),n._zod.parse=(s,r)=>(r.direction==="backward"||s.value===void 0&&(s.value=t.defaultValue),t.innerType._zod.run(s,r))}),xA=$("$ZodNonOptional",(n,t)=>{Xe.init(n,t),ke(n._zod,"values",()=>{const s=t.innerType._zod.values;return s?new Set([...s].filter(r=>r!==void 0)):void 0}),n._zod.parse=(s,r)=>{const l=t.innerType._zod.run(s,r);return l instanceof Promise?l.then(u=>o0(u,n)):o0(l,n)}});function o0(n,t){return!n.issues.length&&n.value===void 0&&n.issues.push({code:"invalid_type",expected:"nonoptional",input:n.value,inst:t}),n}const TA=$("$ZodCatch",(n,t)=>{Xe.init(n,t),ke(n._zod,"optin",()=>t.innerType._zod.optin),ke(n._zod,"optout",()=>t.innerType._zod.optout),ke(n._zod,"values",()=>t.innerType._zod.values),n._zod.parse=(s,r)=>{if(r.direction==="backward")return t.innerType._zod.run(s,r);const l=t.innerType._zod.run(s,r);return l instanceof Promise?l.then(u=>(s.value=u.value,u.issues.length&&(s.value=t.catchValue({...s,error:{issues:u.issues.map(c=>ks(c,r,Ds()))},input:s.value}),s.issues=[]),s)):(s.value=l.value,l.issues.length&&(s.value=t.catchValue({...s,error:{issues:l.issues.map(u=>ks(u,r,Ds()))},input:s.value}),s.issues=[]),s)}}),OA=$("$ZodPipe",(n,t)=>{Xe.init(n,t),ke(n._zod,"values",()=>t.in._zod.values),ke(n._zod,"optin",()=>t.in._zod.optin),ke(n._zod,"optout",()=>t.out._zod.optout),ke(n._zod,"propValues",()=>t.in._zod.propValues),n._zod.parse=(s,r)=>{if(r.direction==="backward"){const u=t.out._zod.run(s,r);return u instanceof Promise?u.then(c=>ru(c,t.in,r)):ru(u,t.in,r)}const l=t.in._zod.run(s,r);return l instanceof Promise?l.then(u=>ru(u,t.out,r)):ru(l,t.out,r)}});function ru(n,t,s){return n.issues.length?(n.aborted=!0,n):t._zod.run({value:n.value,issues:n.issues},s)}const zA=$("$ZodReadonly",(n,t)=>{Xe.init(n,t),ke(n._zod,"propValues",()=>t.innerType._zod.propValues),ke(n._zod,"values",()=>t.innerType._zod.values),ke(n._zod,"optin",()=>{var s,r;return(r=(s=t.innerType)==null?void 0:s._zod)==null?void 0:r.optin}),ke(n._zod,"optout",()=>{var s,r;return(r=(s=t.innerType)==null?void 0:s._zod)==null?void 0:r.optout}),n._zod.parse=(s,r)=>{if(r.direction==="backward")return t.innerType._zod.run(s,r);const l=t.innerType._zod.run(s,r);return l instanceof Promise?l.then(u0):u0(l)}});function u0(n){return n.value=Object.freeze(n.value),n}const RA=$("$ZodCustom",(n,t)=>{Gt.init(n,t),Xe.init(n,t),n._zod.parse=(s,r)=>s,n._zod.check=s=>{const r=s.value,l=t.fn(r);if(l instanceof Promise)return l.then(u=>c0(u,s,r,n));c0(l,s,r,n)}});function c0(n,t,s,r){if(!n){const l={code:"custom",input:s,inst:r,path:[...r._zod.def.path??[]],continue:!r._zod.def.abort};r._zod.def.params&&(l.params=r._zod.def.params),t.issues.push(rl(l))}}var f0;class CA{constructor(){this._map=new WeakMap,this._idmap=new Map}add(t,...s){const r=s[0];return this._map.set(t,r),r&&typeof r=="object"&&"id"in r&&this._idmap.set(r.id,t),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(t){const s=this._map.get(t);return s&&typeof s=="object"&&"id"in s&&this._idmap.delete(s.id),this._map.delete(t),this}get(t){const s=t._zod.parent;if(s){const r={...this.get(s)??{}};delete r.id;const l={...r,...this._map.get(t)};return Object.keys(l).length?l:void 0}return this._map.get(t)}has(t){return this._map.has(t)}}function AA(){return new CA}(f0=globalThis).__zod_globalRegistry??(f0.__zod_globalRegistry=AA());const Ya=globalThis.__zod_globalRegistry;function MA(n,t){return new n({type:"string",...ce(t)})}function DA(n,t){return new n({type:"string",format:"email",check:"string_format",abort:!1,...ce(t)})}function h0(n,t){return new n({type:"string",format:"guid",check:"string_format",abort:!1,...ce(t)})}function kA(n,t){return new n({type:"string",format:"uuid",check:"string_format",abort:!1,...ce(t)})}function NA(n,t){return new n({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v4",...ce(t)})}function jA(n,t){return new n({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v6",...ce(t)})}function UA(n,t){return new n({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v7",...ce(t)})}function BA(n,t){return new n({type:"string",format:"url",check:"string_format",abort:!1,...ce(t)})}function LA(n,t){return new n({type:"string",format:"emoji",check:"string_format",abort:!1,...ce(t)})}function HA(n,t){return new n({type:"string",format:"nanoid",check:"string_format",abort:!1,...ce(t)})}function ZA(n,t){return new n({type:"string",format:"cuid",check:"string_format",abort:!1,...ce(t)})}function $A(n,t){return new n({type:"string",format:"cuid2",check:"string_format",abort:!1,...ce(t)})}function qA(n,t){return new n({type:"string",format:"ulid",check:"string_format",abort:!1,...ce(t)})}function IA(n,t){return new n({type:"string",format:"xid",check:"string_format",abort:!1,...ce(t)})}function KA(n,t){return new n({type:"string",format:"ksuid",check:"string_format",abort:!1,...ce(t)})}function VA(n,t){return new n({type:"string",format:"ipv4",check:"string_format",abort:!1,...ce(t)})}function GA(n,t){return new n({type:"string",format:"ipv6",check:"string_format",abort:!1,...ce(t)})}function YA(n,t){return new n({type:"string",format:"cidrv4",check:"string_format",abort:!1,...ce(t)})}function JA(n,t){return new n({type:"string",format:"cidrv6",check:"string_format",abort:!1,...ce(t)})}function QA(n,t){return new n({type:"string",format:"base64",check:"string_format",abort:!1,...ce(t)})}function XA(n,t){return new n({type:"string",format:"base64url",check:"string_format",abort:!1,...ce(t)})}function FA(n,t){return new n({type:"string",format:"e164",check:"string_format",abort:!1,...ce(t)})}function PA(n,t){return new n({type:"string",format:"jwt",check:"string_format",abort:!1,...ce(t)})}function WA(n,t){return new n({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...ce(t)})}function e2(n,t){return new n({type:"string",format:"date",check:"string_format",...ce(t)})}function t2(n,t){return new n({type:"string",format:"time",check:"string_format",precision:null,...ce(t)})}function n2(n,t){return new n({type:"string",format:"duration",check:"string_format",...ce(t)})}function i2(n,t){return new n({type:"number",checks:[],...ce(t)})}function s2(n,t){return new n({type:"number",check:"number_format",abort:!1,format:"safeint",...ce(t)})}function r2(n,t){return new n({type:"boolean",...ce(t)})}function a2(n){return new n({type:"any"})}function l2(n){return new n({type:"unknown"})}function o2(n,t){return new n({type:"never",...ce(t)})}function d0(n,t){return new KS({check:"less_than",...ce(t),value:n,inclusive:!1})}function kh(n,t){return new KS({check:"less_than",...ce(t),value:n,inclusive:!0})}function p0(n,t){return new VS({check:"greater_than",...ce(t),value:n,inclusive:!1})}function Nh(n,t){return new VS({check:"greater_than",...ce(t),value:n,inclusive:!0})}function m0(n,t){return new EC({check:"multiple_of",...ce(t),value:n})}function FS(n,t){return new TC({check:"max_length",...ce(t),maximum:n})}function Du(n,t){return new OC({check:"min_length",...ce(t),minimum:n})}function PS(n,t){return new zC({check:"length_equals",...ce(t),length:n})}function u2(n,t){return new RC({check:"string_format",format:"regex",...ce(t),pattern:n})}function c2(n){return new CC({check:"string_format",format:"lowercase",...ce(n)})}function f2(n){return new AC({check:"string_format",format:"uppercase",...ce(n)})}function h2(n,t){return new MC({check:"string_format",format:"includes",...ce(t),includes:n})}function d2(n,t){return new DC({check:"string_format",format:"starts_with",...ce(t),prefix:n})}function p2(n,t){return new kC({check:"string_format",format:"ends_with",...ce(t),suffix:n})}function Vr(n){return new NC({check:"overwrite",tx:n})}function m2(n){return Vr(t=>t.normalize(n))}function y2(){return Vr(n=>n.trim())}function g2(){return Vr(n=>n.toLowerCase())}function v2(){return Vr(n=>n.toUpperCase())}function S2(){return Vr(n=>zR(n))}function b2(n,t,s){return new n({type:"array",element:t,...ce(s)})}function w2(n,t,s){return new n({type:"custom",check:"custom",fn:t,...ce(s)})}function _2(n){const t=E2(s=>(s.addIssue=r=>{if(typeof r=="string")s.issues.push(rl(r,s.value,t._zod.def));else{const l=r;l.fatal&&(l.continue=!1),l.code??(l.code="custom"),l.input??(l.input=s.value),l.inst??(l.inst=t),l.continue??(l.continue=!t._zod.def.abort),s.issues.push(rl(l))}},n(s.value,s)));return t}function E2(n,t){const s=new Gt({check:"custom",...ce(t)});return s._zod.check=n,s}function WS(n){let t=(n==null?void 0:n.target)??"draft-2020-12";return t==="draft-4"&&(t="draft-04"),t==="draft-7"&&(t="draft-07"),{processors:n.processors??{},metadataRegistry:(n==null?void 0:n.metadata)??Ya,target:t,unrepresentable:(n==null?void 0:n.unrepresentable)??"throw",override:(n==null?void 0:n.override)??(()=>{}),io:(n==null?void 0:n.io)??"output",counter:0,seen:new Map,cycles:(n==null?void 0:n.cycles)??"ref",reused:(n==null?void 0:n.reused)??"inline",external:(n==null?void 0:n.external)??void 0}}function _t(n,t,s={path:[],schemaPath:[]}){var g,y;var r;const l=n._zod.def,u=t.seen.get(n);if(u)return u.count++,s.schemaPath.includes(n)&&(u.cycle=s.path),u.schema;const c={schema:{},count:1,cycle:void 0,path:s.path};t.seen.set(n,c);const d=(y=(g=n._zod).toJSONSchema)==null?void 0:y.call(g);if(d)c.schema=d;else{const w={...s,schemaPath:[...s.schemaPath,n],path:s.path};if(n._zod.processJSONSchema)n._zod.processJSONSchema(t,c.schema,w);else{const b=c.schema,E=t.processors[l.type];if(!E)throw new Error(`[toJSONSchema]: Non-representable type encountered: ${l.type}`);E(n,t,b,w)}const S=n._zod.parent;S&&(c.ref||(c.ref=S),_t(S,t,w),t.seen.get(S).isParent=!0)}const p=t.metadataRegistry.get(n);return p&&Object.assign(c.schema,p),t.io==="input"&&At(n)&&(delete c.schema.examples,delete c.schema.default),t.io==="input"&&c.schema._prefault&&((r=c.schema).default??(r.default=c.schema._prefault)),delete c.schema._prefault,t.seen.get(n).schema}function eb(n,t){var c,d,p,m;const s=n.seen.get(t);if(!s)throw new Error("Unprocessed schema. This is a bug in Zod.");const r=new Map;for(const g of n.seen.entries()){const y=(c=n.metadataRegistry.get(g[0]))==null?void 0:c.id;if(y){const w=r.get(y);if(w&&w!==g[0])throw new Error(`Duplicate schema id "${y}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`);r.set(y,g[0])}}const l=g=>{var E;const y=n.target==="draft-2020-12"?"$defs":"definitions";if(n.external){const O=(E=n.external.registry.get(g[0]))==null?void 0:E.id,M=n.external.uri??(N=>N);if(O)return{ref:M(O)};const L=g[1].defId??g[1].schema.id??`schema${n.counter++}`;return g[1].defId=L,{defId:L,ref:`${M("__shared")}#/${y}/${L}`}}if(g[1]===s)return{ref:"#"};const S=`#/${y}/`,b=g[1].schema.id??`__schema${n.counter++}`;return{defId:b,ref:S+b}},u=g=>{if(g[1].schema.$ref)return;const y=g[1],{ref:w,defId:S}=l(g);y.def={...y.schema},S&&(y.defId=S);const b=y.schema;for(const E in b)delete b[E];b.$ref=w};if(n.cycles==="throw")for(const g of n.seen.entries()){const y=g[1];if(y.cycle)throw new Error(`Cycle detected: #/${(d=y.cycle)==null?void 0:d.join("/")}/ -Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(const g of n.seen.entries()){const y=g[1];if(t===g[0]){u(g);continue}if(n.external){const S=(p=n.external.registry.get(g[0]))==null?void 0:p.id;if(t!==g[0]&&S){u(g);continue}}if((m=n.metadataRegistry.get(g[0]))==null?void 0:m.id){u(g);continue}if(y.cycle){u(g);continue}if(y.count>1&&n.reused==="ref"){u(g);continue}}}function tb(n,t){var c,d,p;const s=n.seen.get(t);if(!s)throw new Error("Unprocessed schema. This is a bug in Zod.");const r=m=>{const g=n.seen.get(m);if(g.ref===null)return;const y=g.def??g.schema,w={...y},S=g.ref;if(g.ref=null,S){r(S);const E=n.seen.get(S),O=E.schema;if(O.$ref&&(n.target==="draft-07"||n.target==="draft-04"||n.target==="openapi-3.0")?(y.allOf=y.allOf??[],y.allOf.push(O)):Object.assign(y,O),Object.assign(y,w),m._zod.parent===S)for(const L in y)L==="$ref"||L==="allOf"||L in w||delete y[L];if(O.$ref)for(const L in y)L==="$ref"||L==="allOf"||L in E.def&&JSON.stringify(y[L])===JSON.stringify(E.def[L])&&delete y[L]}const b=m._zod.parent;if(b&&b!==S){r(b);const E=n.seen.get(b);if(E!=null&&E.schema.$ref&&(y.$ref=E.schema.$ref,E.def))for(const O in y)O==="$ref"||O==="allOf"||O in E.def&&JSON.stringify(y[O])===JSON.stringify(E.def[O])&&delete y[O]}n.override({zodSchema:m,jsonSchema:y,path:g.path??[]})};for(const m of[...n.seen.entries()].reverse())r(m[0]);const l={};if(n.target==="draft-2020-12"?l.$schema="https://json-schema.org/draft/2020-12/schema":n.target==="draft-07"?l.$schema="http://json-schema.org/draft-07/schema#":n.target==="draft-04"?l.$schema="http://json-schema.org/draft-04/schema#":n.target,(c=n.external)!=null&&c.uri){const m=(d=n.external.registry.get(t))==null?void 0:d.id;if(!m)throw new Error("Schema is missing an `id` property");l.$id=n.external.uri(m)}Object.assign(l,s.def??s.schema);const u=((p=n.external)==null?void 0:p.defs)??{};for(const m of n.seen.entries()){const g=m[1];g.def&&g.defId&&(u[g.defId]=g.def)}n.external||Object.keys(u).length>0&&(n.target==="draft-2020-12"?l.$defs=u:l.definitions=u);try{const m=JSON.parse(JSON.stringify(l));return Object.defineProperty(m,"~standard",{value:{...t["~standard"],jsonSchema:{input:ku(t,"input",n.processors),output:ku(t,"output",n.processors)}},enumerable:!1,writable:!1}),m}catch{throw new Error("Error converting schema to JSON.")}}function At(n,t){const s=t??{seen:new Set};if(s.seen.has(n))return!1;s.seen.add(n);const r=n._zod.def;if(r.type==="transform")return!0;if(r.type==="array")return At(r.element,s);if(r.type==="set")return At(r.valueType,s);if(r.type==="lazy")return At(r.getter(),s);if(r.type==="promise"||r.type==="optional"||r.type==="nonoptional"||r.type==="nullable"||r.type==="readonly"||r.type==="default"||r.type==="prefault")return At(r.innerType,s);if(r.type==="intersection")return At(r.left,s)||At(r.right,s);if(r.type==="record"||r.type==="map")return At(r.keyType,s)||At(r.valueType,s);if(r.type==="pipe")return At(r.in,s)||At(r.out,s);if(r.type==="object"){for(const l in r.shape)if(At(r.shape[l],s))return!0;return!1}if(r.type==="union"){for(const l of r.options)if(At(l,s))return!0;return!1}if(r.type==="tuple"){for(const l of r.items)if(At(l,s))return!0;return!!(r.rest&&At(r.rest,s))}return!1}const x2=(n,t={})=>s=>{const r=WS({...s,processors:t});return _t(n,r),eb(r,n),tb(r,n)},ku=(n,t,s={})=>r=>{const{libraryOptions:l,target:u}=r??{},c=WS({...l??{},target:u,io:t,processors:s});return _t(n,c),eb(c,n),tb(c,n)},T2={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},O2=(n,t,s,r)=>{const l=s;l.type="string";const{minimum:u,maximum:c,format:d,patterns:p,contentEncoding:m}=n._zod.bag;if(typeof u=="number"&&(l.minLength=u),typeof c=="number"&&(l.maxLength=c),d&&(l.format=T2[d]??d,l.format===""&&delete l.format,d==="time"&&delete l.format),m&&(l.contentEncoding=m),p&&p.size>0){const g=[...p];g.length===1?l.pattern=g[0].source:g.length>1&&(l.allOf=[...g.map(y=>({...t.target==="draft-07"||t.target==="draft-04"||t.target==="openapi-3.0"?{type:"string"}:{},pattern:y.source}))])}},z2=(n,t,s,r)=>{const l=s,{minimum:u,maximum:c,format:d,multipleOf:p,exclusiveMaximum:m,exclusiveMinimum:g}=n._zod.bag;typeof d=="string"&&d.includes("int")?l.type="integer":l.type="number",typeof g=="number"&&(t.target==="draft-04"||t.target==="openapi-3.0"?(l.minimum=g,l.exclusiveMinimum=!0):l.exclusiveMinimum=g),typeof u=="number"&&(l.minimum=u,typeof g=="number"&&t.target!=="draft-04"&&(g>=u?delete l.minimum:delete l.exclusiveMinimum)),typeof m=="number"&&(t.target==="draft-04"||t.target==="openapi-3.0"?(l.maximum=m,l.exclusiveMaximum=!0):l.exclusiveMaximum=m),typeof c=="number"&&(l.maximum=c,typeof m=="number"&&t.target!=="draft-04"&&(m<=c?delete l.maximum:delete l.exclusiveMaximum)),typeof p=="number"&&(l.multipleOf=p)},R2=(n,t,s,r)=>{s.type="boolean"},C2=(n,t,s,r)=>{s.not={}},A2=(n,t,s,r)=>{},M2=(n,t,s,r)=>{},D2=(n,t,s,r)=>{const l=n._zod.def,u=kS(l.entries);u.every(c=>typeof c=="number")&&(s.type="number"),u.every(c=>typeof c=="string")&&(s.type="string"),s.enum=u},k2=(n,t,s,r)=>{if(t.unrepresentable==="throw")throw new Error("Custom types cannot be represented in JSON Schema")},N2=(n,t,s,r)=>{if(t.unrepresentable==="throw")throw new Error("Transforms cannot be represented in JSON Schema")},j2=(n,t,s,r)=>{const l=s,u=n._zod.def,{minimum:c,maximum:d}=n._zod.bag;typeof c=="number"&&(l.minItems=c),typeof d=="number"&&(l.maxItems=d),l.type="array",l.items=_t(u.element,t,{...r,path:[...r.path,"items"]})},U2=(n,t,s,r)=>{var m;const l=s,u=n._zod.def;l.type="object",l.properties={};const c=u.shape;for(const g in c)l.properties[g]=_t(c[g],t,{...r,path:[...r.path,"properties",g]});const d=new Set(Object.keys(c)),p=new Set([...d].filter(g=>{const y=u.shape[g]._zod;return t.io==="input"?y.optin===void 0:y.optout===void 0}));p.size>0&&(l.required=Array.from(p)),((m=u.catchall)==null?void 0:m._zod.def.type)==="never"?l.additionalProperties=!1:u.catchall?u.catchall&&(l.additionalProperties=_t(u.catchall,t,{...r,path:[...r.path,"additionalProperties"]})):t.io==="output"&&(l.additionalProperties=!1)},B2=(n,t,s,r)=>{const l=n._zod.def,u=l.inclusive===!1,c=l.options.map((d,p)=>_t(d,t,{...r,path:[...r.path,u?"oneOf":"anyOf",p]}));u?s.oneOf=c:s.anyOf=c},L2=(n,t,s,r)=>{const l=n._zod.def,u=_t(l.left,t,{...r,path:[...r.path,"allOf",0]}),c=_t(l.right,t,{...r,path:[...r.path,"allOf",1]}),d=m=>"allOf"in m&&Object.keys(m).length===1,p=[...d(u)?u.allOf:[u],...d(c)?c.allOf:[c]];s.allOf=p},H2=(n,t,s,r)=>{const l=n._zod.def,u=_t(l.innerType,t,r),c=t.seen.get(n);t.target==="openapi-3.0"?(c.ref=l.innerType,s.nullable=!0):s.anyOf=[u,{type:"null"}]},Z2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType},$2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType,s.default=JSON.parse(JSON.stringify(l.defaultValue))},q2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType,t.io==="input"&&(s._prefault=JSON.parse(JSON.stringify(l.defaultValue)))},I2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType;let c;try{c=l.catchValue(void 0)}catch{throw new Error("Dynamic catch values are not supported in JSON Schema")}s.default=c},K2=(n,t,s,r)=>{const l=n._zod.def,u=t.io==="input"?l.in._zod.def.type==="transform"?l.out:l.in:l.out;_t(u,t,r);const c=t.seen.get(n);c.ref=u},V2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType,s.readOnly=!0},nb=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType},G2=$("ZodISODateTime",(n,t)=>{JC.init(n,t),Ye.init(n,t)});function Y2(n){return WA(G2,n)}const J2=$("ZodISODate",(n,t)=>{QC.init(n,t),Ye.init(n,t)});function Q2(n){return e2(J2,n)}const X2=$("ZodISOTime",(n,t)=>{XC.init(n,t),Ye.init(n,t)});function F2(n){return t2(X2,n)}const P2=$("ZodISODuration",(n,t)=>{FC.init(n,t),Ye.init(n,t)});function W2(n){return n2(P2,n)}const eM=(n,t)=>{LS.init(n,t),n.name="ZodError",Object.defineProperties(n,{format:{value:s=>ZR(n,s)},flatten:{value:s=>HR(n,s)},addIssue:{value:s=>{n.issues.push(s),n.message=JSON.stringify(n.issues,ad,2)}},addIssues:{value:s=>{n.issues.push(...s),n.message=JSON.stringify(n.issues,ad,2)}},isEmpty:{get(){return n.issues.length===0}}})},wn=$("ZodError",eM,{Parent:Error}),tM=$d(wn),nM=qd(wn),iM=Iu(wn),sM=Ku(wn),rM=IR(wn),aM=KR(wn),lM=VR(wn),oM=GR(wn),uM=YR(wn),cM=JR(wn),fM=QR(wn),hM=XR(wn),Fe=$("ZodType",(n,t)=>(Xe.init(n,t),Object.assign(n["~standard"],{jsonSchema:{input:ku(n,"input"),output:ku(n,"output")}}),n.toJSONSchema=x2(n,{}),n.def=t,n.type=t.type,Object.defineProperty(n,"_def",{value:t}),n.check=(...s)=>n.clone(es(t,{checks:[...t.checks??[],...s.map(r=>typeof r=="function"?{_zod:{check:r,def:{check:"custom"},onattach:[]}}:r)]}),{parent:!0}),n.with=n.check,n.clone=(s,r)=>ts(n,s,r),n.brand=()=>n,n.register=((s,r)=>(s.add(n,r),n)),n.parse=(s,r)=>tM(n,s,r,{callee:n.parse}),n.safeParse=(s,r)=>iM(n,s,r),n.parseAsync=async(s,r)=>nM(n,s,r,{callee:n.parseAsync}),n.safeParseAsync=async(s,r)=>sM(n,s,r),n.spa=n.safeParseAsync,n.encode=(s,r)=>rM(n,s,r),n.decode=(s,r)=>aM(n,s,r),n.encodeAsync=async(s,r)=>lM(n,s,r),n.decodeAsync=async(s,r)=>oM(n,s,r),n.safeEncode=(s,r)=>uM(n,s,r),n.safeDecode=(s,r)=>cM(n,s,r),n.safeEncodeAsync=async(s,r)=>fM(n,s,r),n.safeDecodeAsync=async(s,r)=>hM(n,s,r),n.refine=(s,r)=>n.check(oD(s,r)),n.superRefine=s=>n.check(uD(s)),n.overwrite=s=>n.check(Vr(s)),n.optional=()=>S0(n),n.exactOptional=()=>QM(n),n.nullable=()=>b0(n),n.nullish=()=>S0(b0(n)),n.nonoptional=s=>tD(n,s),n.array=()=>ZM(n),n.or=s=>IM([n,s]),n.and=s=>VM(n,s),n.transform=s=>w0(n,YM(s)),n.default=s=>PM(n,s),n.prefault=s=>eD(n,s),n.catch=s=>iD(n,s),n.pipe=s=>w0(n,s),n.readonly=()=>aD(n),n.describe=s=>{const r=n.clone();return Ya.add(r,{description:s}),r},Object.defineProperty(n,"description",{get(){var s;return(s=Ya.get(n))==null?void 0:s.description},configurable:!0}),n.meta=(...s)=>{if(s.length===0)return Ya.get(n);const r=n.clone();return Ya.add(r,s[0]),r},n.isOptional=()=>n.safeParse(void 0).success,n.isNullable=()=>n.safeParse(null).success,n.apply=s=>s(n),n)),ib=$("_ZodString",(n,t)=>{Id.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(r,l,u)=>O2(n,r,l);const s=n._zod.bag;n.format=s.format??null,n.minLength=s.minimum??null,n.maxLength=s.maximum??null,n.regex=(...r)=>n.check(u2(...r)),n.includes=(...r)=>n.check(h2(...r)),n.startsWith=(...r)=>n.check(d2(...r)),n.endsWith=(...r)=>n.check(p2(...r)),n.min=(...r)=>n.check(Du(...r)),n.max=(...r)=>n.check(FS(...r)),n.length=(...r)=>n.check(PS(...r)),n.nonempty=(...r)=>n.check(Du(1,...r)),n.lowercase=r=>n.check(c2(r)),n.uppercase=r=>n.check(f2(r)),n.trim=()=>n.check(y2()),n.normalize=(...r)=>n.check(m2(...r)),n.toLowerCase=()=>n.check(g2()),n.toUpperCase=()=>n.check(v2()),n.slugify=()=>n.check(S2())}),dM=$("ZodString",(n,t)=>{Id.init(n,t),ib.init(n,t),n.email=s=>n.check(DA(pM,s)),n.url=s=>n.check(BA(mM,s)),n.jwt=s=>n.check(PA(AM,s)),n.emoji=s=>n.check(LA(yM,s)),n.guid=s=>n.check(h0(y0,s)),n.uuid=s=>n.check(kA(au,s)),n.uuidv4=s=>n.check(NA(au,s)),n.uuidv6=s=>n.check(jA(au,s)),n.uuidv7=s=>n.check(UA(au,s)),n.nanoid=s=>n.check(HA(gM,s)),n.guid=s=>n.check(h0(y0,s)),n.cuid=s=>n.check(ZA(vM,s)),n.cuid2=s=>n.check($A(SM,s)),n.ulid=s=>n.check(qA(bM,s)),n.base64=s=>n.check(QA(zM,s)),n.base64url=s=>n.check(XA(RM,s)),n.xid=s=>n.check(IA(wM,s)),n.ksuid=s=>n.check(KA(_M,s)),n.ipv4=s=>n.check(VA(EM,s)),n.ipv6=s=>n.check(GA(xM,s)),n.cidrv4=s=>n.check(YA(TM,s)),n.cidrv6=s=>n.check(JA(OM,s)),n.e164=s=>n.check(FA(CM,s)),n.datetime=s=>n.check(Y2(s)),n.date=s=>n.check(Q2(s)),n.time=s=>n.check(F2(s)),n.duration=s=>n.check(W2(s))});function Nt(n){return MA(dM,n)}const Ye=$("ZodStringFormat",(n,t)=>{Ke.init(n,t),ib.init(n,t)}),pM=$("ZodEmail",(n,t)=>{HC.init(n,t),Ye.init(n,t)}),y0=$("ZodGUID",(n,t)=>{BC.init(n,t),Ye.init(n,t)}),au=$("ZodUUID",(n,t)=>{LC.init(n,t),Ye.init(n,t)}),mM=$("ZodURL",(n,t)=>{ZC.init(n,t),Ye.init(n,t)}),yM=$("ZodEmoji",(n,t)=>{$C.init(n,t),Ye.init(n,t)}),gM=$("ZodNanoID",(n,t)=>{qC.init(n,t),Ye.init(n,t)}),vM=$("ZodCUID",(n,t)=>{IC.init(n,t),Ye.init(n,t)}),SM=$("ZodCUID2",(n,t)=>{KC.init(n,t),Ye.init(n,t)}),bM=$("ZodULID",(n,t)=>{VC.init(n,t),Ye.init(n,t)}),wM=$("ZodXID",(n,t)=>{GC.init(n,t),Ye.init(n,t)}),_M=$("ZodKSUID",(n,t)=>{YC.init(n,t),Ye.init(n,t)}),EM=$("ZodIPv4",(n,t)=>{PC.init(n,t),Ye.init(n,t)}),xM=$("ZodIPv6",(n,t)=>{WC.init(n,t),Ye.init(n,t)}),TM=$("ZodCIDRv4",(n,t)=>{eA.init(n,t),Ye.init(n,t)}),OM=$("ZodCIDRv6",(n,t)=>{tA.init(n,t),Ye.init(n,t)}),zM=$("ZodBase64",(n,t)=>{nA.init(n,t),Ye.init(n,t)}),RM=$("ZodBase64URL",(n,t)=>{sA.init(n,t),Ye.init(n,t)}),CM=$("ZodE164",(n,t)=>{rA.init(n,t),Ye.init(n,t)}),AM=$("ZodJWT",(n,t)=>{lA.init(n,t),Ye.init(n,t)}),sb=$("ZodNumber",(n,t)=>{YS.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(r,l,u)=>z2(n,r,l),n.gt=(r,l)=>n.check(p0(r,l)),n.gte=(r,l)=>n.check(Nh(r,l)),n.min=(r,l)=>n.check(Nh(r,l)),n.lt=(r,l)=>n.check(d0(r,l)),n.lte=(r,l)=>n.check(kh(r,l)),n.max=(r,l)=>n.check(kh(r,l)),n.int=r=>n.check(g0(r)),n.safe=r=>n.check(g0(r)),n.positive=r=>n.check(p0(0,r)),n.nonnegative=r=>n.check(Nh(0,r)),n.negative=r=>n.check(d0(0,r)),n.nonpositive=r=>n.check(kh(0,r)),n.multipleOf=(r,l)=>n.check(m0(r,l)),n.step=(r,l)=>n.check(m0(r,l)),n.finite=()=>n;const s=n._zod.bag;n.minValue=Math.max(s.minimum??Number.NEGATIVE_INFINITY,s.exclusiveMinimum??Number.NEGATIVE_INFINITY)??null,n.maxValue=Math.min(s.maximum??Number.POSITIVE_INFINITY,s.exclusiveMaximum??Number.POSITIVE_INFINITY)??null,n.isInt=(s.format??"").includes("int")||Number.isSafeInteger(s.multipleOf??.5),n.isFinite=!0,n.format=s.format??null});function al(n){return i2(sb,n)}const MM=$("ZodNumberFormat",(n,t)=>{oA.init(n,t),sb.init(n,t)});function g0(n){return s2(MM,n)}const DM=$("ZodBoolean",(n,t)=>{uA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>R2(n,s,r)});function kM(n){return r2(DM,n)}const NM=$("ZodAny",(n,t)=>{cA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>A2()});function jM(){return a2(NM)}const UM=$("ZodUnknown",(n,t)=>{fA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>M2()});function v0(){return l2(UM)}const BM=$("ZodNever",(n,t)=>{hA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>C2(n,s,r)});function LM(n){return o2(BM,n)}const HM=$("ZodArray",(n,t)=>{dA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>j2(n,s,r,l),n.element=t.element,n.min=(s,r)=>n.check(Du(s,r)),n.nonempty=s=>n.check(Du(1,s)),n.max=(s,r)=>n.check(FS(s,r)),n.length=(s,r)=>n.check(PS(s,r)),n.unwrap=()=>n.element});function ZM(n,t){return b2(HM,n,t)}const $M=$("ZodObject",(n,t)=>{mA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>U2(n,s,r,l),ke(n,"shape",()=>t.shape),n.keyof=()=>Kd(Object.keys(n._zod.def.shape)),n.catchall=s=>n.clone({...n._zod.def,catchall:s}),n.passthrough=()=>n.clone({...n._zod.def,catchall:v0()}),n.loose=()=>n.clone({...n._zod.def,catchall:v0()}),n.strict=()=>n.clone({...n._zod.def,catchall:LM()}),n.strip=()=>n.clone({...n._zod.def,catchall:void 0}),n.extend=s=>NR(n,s),n.safeExtend=s=>jR(n,s),n.merge=s=>UR(n,s),n.pick=s=>DR(n,s),n.omit=s=>kR(n,s),n.partial=(...s)=>BR(rb,n,s[0]),n.required=(...s)=>LR(ab,n,s[0])});function Gu(n,t){const s={type:"object",shape:n??{},...ce(t)};return new $M(s)}const qM=$("ZodUnion",(n,t)=>{yA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>B2(n,s,r,l),n.options=t.options});function IM(n,t){return new qM({type:"union",options:n,...ce(t)})}const KM=$("ZodIntersection",(n,t)=>{gA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>L2(n,s,r,l)});function VM(n,t){return new KM({type:"intersection",left:n,right:t})}const od=$("ZodEnum",(n,t)=>{vA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(r,l,u)=>D2(n,r,l),n.enum=t.entries,n.options=Object.values(t.entries);const s=new Set(Object.keys(t.entries));n.extract=(r,l)=>{const u={};for(const c of r)if(s.has(c))u[c]=t.entries[c];else throw new Error(`Key ${c} not found in enum`);return new od({...t,checks:[],...ce(l),entries:u})},n.exclude=(r,l)=>{const u={...t.entries};for(const c of r)if(s.has(c))delete u[c];else throw new Error(`Key ${c} not found in enum`);return new od({...t,checks:[],...ce(l),entries:u})}});function Kd(n,t){const s=Array.isArray(n)?Object.fromEntries(n.map(r=>[r,r])):n;return new od({type:"enum",entries:s,...ce(t)})}const GM=$("ZodTransform",(n,t)=>{SA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>N2(n,s),n._zod.parse=(s,r)=>{if(r.direction==="backward")throw new MS(n.constructor.name);s.addIssue=u=>{if(typeof u=="string")s.issues.push(rl(u,s.value,t));else{const c=u;c.fatal&&(c.continue=!1),c.code??(c.code="custom"),c.input??(c.input=s.value),c.inst??(c.inst=n),s.issues.push(rl(c))}};const l=t.transform(s.value,s);return l instanceof Promise?l.then(u=>(s.value=u,s)):(s.value=l,s)}});function YM(n){return new GM({type:"transform",transform:n})}const rb=$("ZodOptional",(n,t)=>{XS.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>nb(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function S0(n){return new rb({type:"optional",innerType:n})}const JM=$("ZodExactOptional",(n,t)=>{bA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>nb(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function QM(n){return new JM({type:"optional",innerType:n})}const XM=$("ZodNullable",(n,t)=>{wA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>H2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function b0(n){return new XM({type:"nullable",innerType:n})}const FM=$("ZodDefault",(n,t)=>{_A.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>$2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType,n.removeDefault=n.unwrap});function PM(n,t){return new FM({type:"default",innerType:n,get defaultValue(){return typeof t=="function"?t():jS(t)}})}const WM=$("ZodPrefault",(n,t)=>{EA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>q2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function eD(n,t){return new WM({type:"prefault",innerType:n,get defaultValue(){return typeof t=="function"?t():jS(t)}})}const ab=$("ZodNonOptional",(n,t)=>{xA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>Z2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function tD(n,t){return new ab({type:"nonoptional",innerType:n,...ce(t)})}const nD=$("ZodCatch",(n,t)=>{TA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>I2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType,n.removeCatch=n.unwrap});function iD(n,t){return new nD({type:"catch",innerType:n,catchValue:typeof t=="function"?t:()=>t})}const sD=$("ZodPipe",(n,t)=>{OA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>K2(n,s,r,l),n.in=t.in,n.out=t.out});function w0(n,t){return new sD({type:"pipe",in:n,out:t})}const rD=$("ZodReadonly",(n,t)=>{zA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>V2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function aD(n){return new rD({type:"readonly",innerType:n})}const lD=$("ZodCustom",(n,t)=>{RA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>k2(n,s)});function oD(n,t={}){return w2(lD,n,t)}function uD(n){return _2(n)}const cD=Gu({id:Nt(),repo:Nt(),branch:Nt(),spec:Nt(),status:Kd(["running","needs_input","blocked","done"]),iteration:al(),started_at:Nt()}),fD=Gu({id:Nt(),session_id:Nt(),order:al(),content:Nt(),status:Kd(["pending","in_progress","completed"])}),hD=Gu({id:Nt(),session_id:Nt(),iteration:al(),sequence:al(),message:jM(),timestamp:Nt()}),dD=Gu({id:Nt(),session_id:Nt(),iteration:al(),question:Nt(),responded:kM(),response:Nt().nullable()}),pD=xR({sessions:{schema:cD,type:"session",primaryKey:"id"},tasks:{schema:fD,type:"task",primaryKey:"id"},claude_events:{schema:hD,type:"claude_event",primaryKey:"id"},input_requests:{schema:dD,type:"input_request",primaryKey:"id"}});function mD(n){return TR({streamOptions:{url:"/stream",headers:{Authorization:`Bearer ${n}`}},state:pD})}function yD(n,t){return{submitInput:aS({onMutate:({requestId:r,response:l})=>{n.collections.input_requests.update(r,u=>{u.responded=!0,u.response=l})},mutationFn:async({requestId:r,response:l})=>{const u=await fetch("/input",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({request_id:r,response:l})});if(!u.ok)throw new Error(`Failed to submit input: ${u.status}`)}})}}function gD({onLogin:n}){const[t,s]=j.useState(""),[r,l]=j.useState(null),[u,c]=j.useState(!1),d=async p=>{p.preventDefault(),c(!0),l(null);try{const m=await fetch("/auth",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:t})});if(!m.ok){m.status===401?l("Invalid password"):l(`Authentication failed: ${m.status}`),c(!1);return}const g=await m.json();n(g.token)}catch(m){l(m instanceof Error?m.message:"Connection failed"),c(!1)}};return Y.jsx("div",{className:"login",children:Y.jsxs("div",{className:"login-card",children:[Y.jsx("h1",{children:"Wisp"}),Y.jsx("p",{children:"Remote Access"}),Y.jsxs("form",{onSubmit:d,children:[Y.jsx("input",{type:"password",value:t,onChange:p=>s(p.target.value),placeholder:"Password",disabled:u,autoFocus:!0}),Y.jsx("button",{type:"submit",disabled:u||!t,children:u?"Connecting...":"Login"}),r&&Y.jsx("div",{className:"error-message",children:r})]})]})})}const jh=1;function ll(n,t=[]){const s=n&&typeof n=="object"&&typeof n.subscribeChanges=="function"&&typeof n.startSyncImmediate=="function"&&typeof n.id=="string",r=j.useRef(null),l=j.useRef(null),u=j.useRef(null),c=j.useRef(0),d=j.useRef(null),p=!r.current||s&&u.current!==n||!s&&(l.current===null||l.current.length!==t.length||l.current.some((b,E)=>b!==t[E]));if(p)if(s)n.startSyncImmediate(),r.current=n,u.current=n;else if(typeof n=="function"){const b=new ot,E=n(b);if(E==null)r.current=null;else if(E instanceof Ad)E.startSyncImmediate(),r.current=E;else if(E instanceof ot)r.current=Rh({query:n,startSync:!0,gcTime:jh});else if(E&&typeof E=="object")r.current=Rh({startSync:!0,gcTime:jh,...E});else throw new Error(`useLiveQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof E}`);l.current=[...t]}else r.current=Rh({startSync:!0,gcTime:jh,...n}),l.current=[...t];p&&(c.current=0,d.current=null);const m=j.useRef(null);(!m.current||p)&&(m.current=b=>{if(!r.current)return()=>{};const E=r.current.subscribeChanges(()=>{c.current+=1,b()});return r.current.status==="ready"&&(c.current+=1,b()),()=>{E.unsubscribe()}});const g=j.useRef(null);(!g.current||p)&&(g.current=()=>{const b=c.current,E=r.current;return(!d.current||d.current.version!==b||d.current.collection!==E)&&(d.current={collection:E,version:b}),d.current});const y=j.useSyncExternalStore(m.current,g.current),w=j.useRef(null),S=j.useRef(null);if(!w.current||w.current.version!==y.version||w.current.collection!==y.collection){if(!y.collection)S.current={state:void 0,data:void 0,collection:void 0,status:"disabled",isLoading:!1,isReady:!0,isIdle:!1,isError:!1,isCleanedUp:!1,isEnabled:!1};else{const b=Array.from(y.collection.entries()),O=y.collection.config.singleResult;let M=null,L=null;S.current={get state(){return M||(M=new Map(b)),M},get data(){return L||(L=b.map(([,N])=>N)),O?L[0]:L},collection:y.collection,status:y.collection.status,isLoading:y.collection.status==="loading",isReady:y.collection.status==="ready",isIdle:y.collection.status==="idle",isError:y.collection.status==="error",isCleanedUp:y.collection.status==="cleaned-up",isEnabled:!0}}w.current=y}return S.current}function vD({db:n}){var s,r;const t=ll(l=>l.from({sessions:n.collections.sessions}).orderBy(({sessions:u})=>u.started_at,"desc"));return Y.jsxs("div",{className:"dashboard",children:[Y.jsx("h2",{children:"Sessions"}),((s=t.data)==null?void 0:s.length)===0?Y.jsx("p",{className:"no-sessions",children:"No active sessions"}):Y.jsx("div",{className:"session-list",children:(r=t.data)==null?void 0:r.map(l=>Y.jsxs(gd,{to:`/session/${l.id}`,className:`session-card status-${l.status}`,children:[Y.jsx("div",{className:"session-repo",children:l.repo}),Y.jsx("div",{className:"session-branch",children:l.branch}),Y.jsxs("div",{className:"session-meta",children:[Y.jsx("span",{className:`status-badge ${l.status}`,children:l.status}),Y.jsxs("span",{className:"iteration",children:["Iteration ",l.iteration]})]})]},l.id))})]})}function SD({db:n,sessionId:t}){var r,l;const s=ll(u=>u.from({tasks:n.collections.tasks}).where(({tasks:c})=>Fi(c.session_id,t)).orderBy(({tasks:c})=>c.order,"asc"));return Y.jsxs("div",{className:"task-list",children:[Y.jsx("h3",{children:"Tasks"}),((r=s.data)==null?void 0:r.length)===0?Y.jsx("p",{className:"no-tasks",children:"No tasks yet"}):Y.jsx("ul",{children:(l=s.data)==null?void 0:l.map(u=>Y.jsxs("li",{className:`task status-${u.status}`,children:[Y.jsxs("span",{className:"task-status-icon",children:[u.status==="completed"&&"✓",u.status==="in_progress"&&"→",u.status==="pending"&&"○"]}),Y.jsx("span",{className:"task-content",children:u.content})]},u.id))})]})}function bD({db:n,sessionId:t}){var l,u;const s=j.useRef(null),r=ll(c=>c.from({events:n.collections.claude_events}).where(({events:d})=>Fi(d.session_id,t)).orderBy(({events:d})=>d.sequence,"asc"));return j.useEffect(()=>{var c;(c=s.current)==null||c.scrollIntoView({behavior:"smooth"})},[(l=r.data)==null?void 0:l.length]),Y.jsxs("div",{className:"output-log",children:[Y.jsx("h3",{children:"Output"}),Y.jsxs("div",{className:"output-content",children:[(u=r.data)==null?void 0:u.map(c=>Y.jsx(wD,{event:c},c.id)),Y.jsx("div",{ref:s})]})]})}function wD({event:n}){var s,r,l,u;const t=n.message;switch(t.type){case"assistant":return Y.jsx("div",{className:"event assistant",children:(r=(s=t.message)==null?void 0:s.content)==null?void 0:r.map((c,d)=>Y.jsx(_D,{block:c},d))});case"user":return Y.jsx("div",{className:"event tool-result",children:Y.jsx("pre",{children:JSON.stringify((l=t.message)==null?void 0:l.content,null,2)})});case"result":return Y.jsx("div",{className:"event result",children:t.subtype==="success"?`Done: ${t.num_turns} turns, $${((u=t.total_cost_usd)==null?void 0:u.toFixed(2))||"?"}`:`Error: ${t.subtype}`});case"system":return Y.jsxs("div",{className:"event system",children:["Session: ",t.session_id]});default:return null}}function _D({block:n}){return n.type==="text"?Y.jsx("div",{className:"content-text",children:n.text}):n.type==="tool_use"?Y.jsxs("details",{className:"content-tool-use",children:[Y.jsxs("summary",{children:["[",n.name,"]"]}),Y.jsx("pre",{children:JSON.stringify(n.input,null,2)})]}):null}function ED({actions:n,request:t}){const[s,r]=j.useState("");j.useEffect(()=>{!t.responded&&document.hidden&&xD().then(()=>{TD("Wisp needs input",t.question)})},[t.id,t.responded,t.question]);const l=u=>{u.preventDefault(),s.trim()&&(n.submitInput({requestId:t.id,response:s}),r(""))};return t.responded?Y.jsxs("div",{className:"input-prompt responded",children:[Y.jsx("p",{className:"question",children:t.question}),Y.jsxs("p",{className:"response",children:["Responded: ",t.response]})]}):Y.jsx("div",{className:"input-prompt",children:Y.jsxs("form",{onSubmit:l,children:[Y.jsx("p",{className:"question",children:t.question}),Y.jsxs("div",{className:"input-row",children:[Y.jsx("input",{type:"text",value:s,onChange:u=>r(u.target.value),placeholder:"Type your response...",autoFocus:!0}),Y.jsx("button",{type:"submit",disabled:!s.trim(),children:"Send"})]})]})})}async function xD(){return"Notification"in window?Notification.permission==="granted"?!0:Notification.permission!=="denied"?await Notification.requestPermission()==="granted":!1:!1}function TD(n,t){if(Notification.permission==="granted"){const s=new Notification(n,{body:t});s.onclick=()=>{window.focus(),s.close()}}}function OD({db:n,actions:t}){var d,p;const{id:s}=TE(),r=ll(m=>m.from({sessions:n.collections.sessions}).where(({sessions:g})=>Fi(g.id,s)).limit(1)),l=ll(m=>m.from({requests:n.collections.input_requests}).where(({requests:g})=>Fi(g.session_id,s)).where(({requests:g})=>Fi(g.responded,!1)).orderBy(({requests:g})=>g.iteration,"desc").limit(1)),u=(d=r.data)==null?void 0:d[0],c=(p=l.data)==null?void 0:p[0];return s?u?Y.jsxs("div",{className:"session",children:[Y.jsxs("header",{className:"session-header",children:[Y.jsxs("div",{className:"session-info",children:[Y.jsx("h2",{children:u.repo}),Y.jsx("p",{className:"branch",children:u.branch})]}),Y.jsxs("div",{className:"session-status",children:[Y.jsx("span",{className:`status-badge ${u.status}`,children:u.status}),Y.jsxs("span",{className:"iteration",children:["Iteration ",u.iteration]})]})]}),Y.jsxs("div",{className:"session-content",children:[Y.jsx("aside",{className:"session-sidebar",children:Y.jsx(SD,{db:n,sessionId:s})}),Y.jsxs("section",{className:"session-main",children:[c&&Y.jsx(ED,{db:n,actions:t,request:c}),Y.jsx(bD,{db:n,sessionId:s})]})]})]}):Y.jsx("div",{className:"session-loading",children:Y.jsx("p",{children:"Loading session..."})}):Y.jsx(L0,{to:"/",replace:!0})}function zD(){const[n,t]=j.useState(null),[s,r]=j.useState(null),[l,u]=j.useState(!1),[c,d]=j.useState(null);j.useEffect(()=>{if(n){u(!0),d(null);const g=mD(n);return g.preload().then(()=>{r(g),u(!1)}).catch(y=>{d(y.message),u(!1),t(null)}),()=>g.close()}else r(null)},[n]);const p=()=>{t(null),r(null)};if(!n)return Y.jsx(gD,{onLogin:t});if(l)return Y.jsx("div",{className:"loading",children:Y.jsx("p",{children:"Connecting..."})});if(c)return Y.jsxs("div",{className:"error",children:[Y.jsxs("p",{children:["Connection error: ",c]}),Y.jsx("button",{onClick:()=>t(null),children:"Try Again"})]});if(!s)return null;const m=yD(s,n);return Y.jsx(c1,{children:Y.jsxs("div",{className:"app",children:[Y.jsxs("header",{className:"app-header",children:[Y.jsx("h1",{children:"Wisp"}),Y.jsx("button",{onClick:p,className:"logout-btn",children:"Logout"})]}),Y.jsx("main",{children:Y.jsxs(ZE,{children:[Y.jsx(lu,{path:"/",element:Y.jsx(vD,{db:s})}),Y.jsx(lu,{path:"/session/:id",element:Y.jsx(OD,{db:s,actions:m})}),Y.jsx(lu,{path:"*",element:Y.jsx(L0,{to:"/",replace:!0})})]})})]})})}q_.createRoot(document.getElementById("root")).render(Y.jsx(j.StrictMode,{children:Y.jsx(zD,{})})); +Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(const g of n.seen.entries()){const y=g[1];if(t===g[0]){u(g);continue}if(n.external){const S=(p=n.external.registry.get(g[0]))==null?void 0:p.id;if(t!==g[0]&&S){u(g);continue}}if((m=n.metadataRegistry.get(g[0]))==null?void 0:m.id){u(g);continue}if(y.cycle){u(g);continue}if(y.count>1&&n.reused==="ref"){u(g);continue}}}function tb(n,t){var c,d,p;const s=n.seen.get(t);if(!s)throw new Error("Unprocessed schema. This is a bug in Zod.");const r=m=>{const g=n.seen.get(m);if(g.ref===null)return;const y=g.def??g.schema,w={...y},S=g.ref;if(g.ref=null,S){r(S);const E=n.seen.get(S),O=E.schema;if(O.$ref&&(n.target==="draft-07"||n.target==="draft-04"||n.target==="openapi-3.0")?(y.allOf=y.allOf??[],y.allOf.push(O)):Object.assign(y,O),Object.assign(y,w),m._zod.parent===S)for(const L in y)L==="$ref"||L==="allOf"||L in w||delete y[L];if(O.$ref)for(const L in y)L==="$ref"||L==="allOf"||L in E.def&&JSON.stringify(y[L])===JSON.stringify(E.def[L])&&delete y[L]}const b=m._zod.parent;if(b&&b!==S){r(b);const E=n.seen.get(b);if(E!=null&&E.schema.$ref&&(y.$ref=E.schema.$ref,E.def))for(const O in y)O==="$ref"||O==="allOf"||O in E.def&&JSON.stringify(y[O])===JSON.stringify(E.def[O])&&delete y[O]}n.override({zodSchema:m,jsonSchema:y,path:g.path??[]})};for(const m of[...n.seen.entries()].reverse())r(m[0]);const l={};if(n.target==="draft-2020-12"?l.$schema="https://json-schema.org/draft/2020-12/schema":n.target==="draft-07"?l.$schema="http://json-schema.org/draft-07/schema#":n.target==="draft-04"?l.$schema="http://json-schema.org/draft-04/schema#":n.target,(c=n.external)!=null&&c.uri){const m=(d=n.external.registry.get(t))==null?void 0:d.id;if(!m)throw new Error("Schema is missing an `id` property");l.$id=n.external.uri(m)}Object.assign(l,s.def??s.schema);const u=((p=n.external)==null?void 0:p.defs)??{};for(const m of n.seen.entries()){const g=m[1];g.def&&g.defId&&(u[g.defId]=g.def)}n.external||Object.keys(u).length>0&&(n.target==="draft-2020-12"?l.$defs=u:l.definitions=u);try{const m=JSON.parse(JSON.stringify(l));return Object.defineProperty(m,"~standard",{value:{...t["~standard"],jsonSchema:{input:ku(t,"input",n.processors),output:ku(t,"output",n.processors)}},enumerable:!1,writable:!1}),m}catch{throw new Error("Error converting schema to JSON.")}}function At(n,t){const s=t??{seen:new Set};if(s.seen.has(n))return!1;s.seen.add(n);const r=n._zod.def;if(r.type==="transform")return!0;if(r.type==="array")return At(r.element,s);if(r.type==="set")return At(r.valueType,s);if(r.type==="lazy")return At(r.getter(),s);if(r.type==="promise"||r.type==="optional"||r.type==="nonoptional"||r.type==="nullable"||r.type==="readonly"||r.type==="default"||r.type==="prefault")return At(r.innerType,s);if(r.type==="intersection")return At(r.left,s)||At(r.right,s);if(r.type==="record"||r.type==="map")return At(r.keyType,s)||At(r.valueType,s);if(r.type==="pipe")return At(r.in,s)||At(r.out,s);if(r.type==="object"){for(const l in r.shape)if(At(r.shape[l],s))return!0;return!1}if(r.type==="union"){for(const l of r.options)if(At(l,s))return!0;return!1}if(r.type==="tuple"){for(const l of r.items)if(At(l,s))return!0;return!!(r.rest&&At(r.rest,s))}return!1}const x2=(n,t={})=>s=>{const r=WS({...s,processors:t});return _t(n,r),eb(r,n),tb(r,n)},ku=(n,t,s={})=>r=>{const{libraryOptions:l,target:u}=r??{},c=WS({...l??{},target:u,io:t,processors:s});return _t(n,c),eb(c,n),tb(c,n)},T2={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},O2=(n,t,s,r)=>{const l=s;l.type="string";const{minimum:u,maximum:c,format:d,patterns:p,contentEncoding:m}=n._zod.bag;if(typeof u=="number"&&(l.minLength=u),typeof c=="number"&&(l.maxLength=c),d&&(l.format=T2[d]??d,l.format===""&&delete l.format,d==="time"&&delete l.format),m&&(l.contentEncoding=m),p&&p.size>0){const g=[...p];g.length===1?l.pattern=g[0].source:g.length>1&&(l.allOf=[...g.map(y=>({...t.target==="draft-07"||t.target==="draft-04"||t.target==="openapi-3.0"?{type:"string"}:{},pattern:y.source}))])}},z2=(n,t,s,r)=>{const l=s,{minimum:u,maximum:c,format:d,multipleOf:p,exclusiveMaximum:m,exclusiveMinimum:g}=n._zod.bag;typeof d=="string"&&d.includes("int")?l.type="integer":l.type="number",typeof g=="number"&&(t.target==="draft-04"||t.target==="openapi-3.0"?(l.minimum=g,l.exclusiveMinimum=!0):l.exclusiveMinimum=g),typeof u=="number"&&(l.minimum=u,typeof g=="number"&&t.target!=="draft-04"&&(g>=u?delete l.minimum:delete l.exclusiveMinimum)),typeof m=="number"&&(t.target==="draft-04"||t.target==="openapi-3.0"?(l.maximum=m,l.exclusiveMaximum=!0):l.exclusiveMaximum=m),typeof c=="number"&&(l.maximum=c,typeof m=="number"&&t.target!=="draft-04"&&(m<=c?delete l.maximum:delete l.exclusiveMaximum)),typeof p=="number"&&(l.multipleOf=p)},R2=(n,t,s,r)=>{s.type="boolean"},C2=(n,t,s,r)=>{s.not={}},A2=(n,t,s,r)=>{},M2=(n,t,s,r)=>{},D2=(n,t,s,r)=>{const l=n._zod.def,u=kS(l.entries);u.every(c=>typeof c=="number")&&(s.type="number"),u.every(c=>typeof c=="string")&&(s.type="string"),s.enum=u},k2=(n,t,s,r)=>{if(t.unrepresentable==="throw")throw new Error("Custom types cannot be represented in JSON Schema")},N2=(n,t,s,r)=>{if(t.unrepresentable==="throw")throw new Error("Transforms cannot be represented in JSON Schema")},j2=(n,t,s,r)=>{const l=s,u=n._zod.def,{minimum:c,maximum:d}=n._zod.bag;typeof c=="number"&&(l.minItems=c),typeof d=="number"&&(l.maxItems=d),l.type="array",l.items=_t(u.element,t,{...r,path:[...r.path,"items"]})},U2=(n,t,s,r)=>{var m;const l=s,u=n._zod.def;l.type="object",l.properties={};const c=u.shape;for(const g in c)l.properties[g]=_t(c[g],t,{...r,path:[...r.path,"properties",g]});const d=new Set(Object.keys(c)),p=new Set([...d].filter(g=>{const y=u.shape[g]._zod;return t.io==="input"?y.optin===void 0:y.optout===void 0}));p.size>0&&(l.required=Array.from(p)),((m=u.catchall)==null?void 0:m._zod.def.type)==="never"?l.additionalProperties=!1:u.catchall?u.catchall&&(l.additionalProperties=_t(u.catchall,t,{...r,path:[...r.path,"additionalProperties"]})):t.io==="output"&&(l.additionalProperties=!1)},B2=(n,t,s,r)=>{const l=n._zod.def,u=l.inclusive===!1,c=l.options.map((d,p)=>_t(d,t,{...r,path:[...r.path,u?"oneOf":"anyOf",p]}));u?s.oneOf=c:s.anyOf=c},L2=(n,t,s,r)=>{const l=n._zod.def,u=_t(l.left,t,{...r,path:[...r.path,"allOf",0]}),c=_t(l.right,t,{...r,path:[...r.path,"allOf",1]}),d=m=>"allOf"in m&&Object.keys(m).length===1,p=[...d(u)?u.allOf:[u],...d(c)?c.allOf:[c]];s.allOf=p},H2=(n,t,s,r)=>{const l=n._zod.def,u=_t(l.innerType,t,r),c=t.seen.get(n);t.target==="openapi-3.0"?(c.ref=l.innerType,s.nullable=!0):s.anyOf=[u,{type:"null"}]},Z2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType},$2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType,s.default=JSON.parse(JSON.stringify(l.defaultValue))},q2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType,t.io==="input"&&(s._prefault=JSON.parse(JSON.stringify(l.defaultValue)))},I2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType;let c;try{c=l.catchValue(void 0)}catch{throw new Error("Dynamic catch values are not supported in JSON Schema")}s.default=c},K2=(n,t,s,r)=>{const l=n._zod.def,u=t.io==="input"?l.in._zod.def.type==="transform"?l.out:l.in:l.out;_t(u,t,r);const c=t.seen.get(n);c.ref=u},V2=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType,s.readOnly=!0},nb=(n,t,s,r)=>{const l=n._zod.def;_t(l.innerType,t,r);const u=t.seen.get(n);u.ref=l.innerType},G2=$("ZodISODateTime",(n,t)=>{JC.init(n,t),Ye.init(n,t)});function Y2(n){return WA(G2,n)}const J2=$("ZodISODate",(n,t)=>{QC.init(n,t),Ye.init(n,t)});function Q2(n){return e2(J2,n)}const X2=$("ZodISOTime",(n,t)=>{XC.init(n,t),Ye.init(n,t)});function F2(n){return t2(X2,n)}const P2=$("ZodISODuration",(n,t)=>{FC.init(n,t),Ye.init(n,t)});function W2(n){return n2(P2,n)}const eM=(n,t)=>{LS.init(n,t),n.name="ZodError",Object.defineProperties(n,{format:{value:s=>ZR(n,s)},flatten:{value:s=>HR(n,s)},addIssue:{value:s=>{n.issues.push(s),n.message=JSON.stringify(n.issues,ad,2)}},addIssues:{value:s=>{n.issues.push(...s),n.message=JSON.stringify(n.issues,ad,2)}},isEmpty:{get(){return n.issues.length===0}}})},wn=$("ZodError",eM,{Parent:Error}),tM=$d(wn),nM=qd(wn),iM=Iu(wn),sM=Ku(wn),rM=IR(wn),aM=KR(wn),lM=VR(wn),oM=GR(wn),uM=YR(wn),cM=JR(wn),fM=QR(wn),hM=XR(wn),Fe=$("ZodType",(n,t)=>(Xe.init(n,t),Object.assign(n["~standard"],{jsonSchema:{input:ku(n,"input"),output:ku(n,"output")}}),n.toJSONSchema=x2(n,{}),n.def=t,n.type=t.type,Object.defineProperty(n,"_def",{value:t}),n.check=(...s)=>n.clone(es(t,{checks:[...t.checks??[],...s.map(r=>typeof r=="function"?{_zod:{check:r,def:{check:"custom"},onattach:[]}}:r)]}),{parent:!0}),n.with=n.check,n.clone=(s,r)=>ts(n,s,r),n.brand=()=>n,n.register=((s,r)=>(s.add(n,r),n)),n.parse=(s,r)=>tM(n,s,r,{callee:n.parse}),n.safeParse=(s,r)=>iM(n,s,r),n.parseAsync=async(s,r)=>nM(n,s,r,{callee:n.parseAsync}),n.safeParseAsync=async(s,r)=>sM(n,s,r),n.spa=n.safeParseAsync,n.encode=(s,r)=>rM(n,s,r),n.decode=(s,r)=>aM(n,s,r),n.encodeAsync=async(s,r)=>lM(n,s,r),n.decodeAsync=async(s,r)=>oM(n,s,r),n.safeEncode=(s,r)=>uM(n,s,r),n.safeDecode=(s,r)=>cM(n,s,r),n.safeEncodeAsync=async(s,r)=>fM(n,s,r),n.safeDecodeAsync=async(s,r)=>hM(n,s,r),n.refine=(s,r)=>n.check(oD(s,r)),n.superRefine=s=>n.check(uD(s)),n.overwrite=s=>n.check(Vr(s)),n.optional=()=>S0(n),n.exactOptional=()=>QM(n),n.nullable=()=>b0(n),n.nullish=()=>S0(b0(n)),n.nonoptional=s=>tD(n,s),n.array=()=>ZM(n),n.or=s=>IM([n,s]),n.and=s=>VM(n,s),n.transform=s=>w0(n,YM(s)),n.default=s=>PM(n,s),n.prefault=s=>eD(n,s),n.catch=s=>iD(n,s),n.pipe=s=>w0(n,s),n.readonly=()=>aD(n),n.describe=s=>{const r=n.clone();return Ya.add(r,{description:s}),r},Object.defineProperty(n,"description",{get(){var s;return(s=Ya.get(n))==null?void 0:s.description},configurable:!0}),n.meta=(...s)=>{if(s.length===0)return Ya.get(n);const r=n.clone();return Ya.add(r,s[0]),r},n.isOptional=()=>n.safeParse(void 0).success,n.isNullable=()=>n.safeParse(null).success,n.apply=s=>s(n),n)),ib=$("_ZodString",(n,t)=>{Id.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(r,l,u)=>O2(n,r,l);const s=n._zod.bag;n.format=s.format??null,n.minLength=s.minimum??null,n.maxLength=s.maximum??null,n.regex=(...r)=>n.check(u2(...r)),n.includes=(...r)=>n.check(h2(...r)),n.startsWith=(...r)=>n.check(d2(...r)),n.endsWith=(...r)=>n.check(p2(...r)),n.min=(...r)=>n.check(Du(...r)),n.max=(...r)=>n.check(FS(...r)),n.length=(...r)=>n.check(PS(...r)),n.nonempty=(...r)=>n.check(Du(1,...r)),n.lowercase=r=>n.check(c2(r)),n.uppercase=r=>n.check(f2(r)),n.trim=()=>n.check(y2()),n.normalize=(...r)=>n.check(m2(...r)),n.toLowerCase=()=>n.check(g2()),n.toUpperCase=()=>n.check(v2()),n.slugify=()=>n.check(S2())}),dM=$("ZodString",(n,t)=>{Id.init(n,t),ib.init(n,t),n.email=s=>n.check(DA(pM,s)),n.url=s=>n.check(BA(mM,s)),n.jwt=s=>n.check(PA(AM,s)),n.emoji=s=>n.check(LA(yM,s)),n.guid=s=>n.check(h0(y0,s)),n.uuid=s=>n.check(kA(au,s)),n.uuidv4=s=>n.check(NA(au,s)),n.uuidv6=s=>n.check(jA(au,s)),n.uuidv7=s=>n.check(UA(au,s)),n.nanoid=s=>n.check(HA(gM,s)),n.guid=s=>n.check(h0(y0,s)),n.cuid=s=>n.check(ZA(vM,s)),n.cuid2=s=>n.check($A(SM,s)),n.ulid=s=>n.check(qA(bM,s)),n.base64=s=>n.check(QA(zM,s)),n.base64url=s=>n.check(XA(RM,s)),n.xid=s=>n.check(IA(wM,s)),n.ksuid=s=>n.check(KA(_M,s)),n.ipv4=s=>n.check(VA(EM,s)),n.ipv6=s=>n.check(GA(xM,s)),n.cidrv4=s=>n.check(YA(TM,s)),n.cidrv6=s=>n.check(JA(OM,s)),n.e164=s=>n.check(FA(CM,s)),n.datetime=s=>n.check(Y2(s)),n.date=s=>n.check(Q2(s)),n.time=s=>n.check(F2(s)),n.duration=s=>n.check(W2(s))});function Nt(n){return MA(dM,n)}const Ye=$("ZodStringFormat",(n,t)=>{Ke.init(n,t),ib.init(n,t)}),pM=$("ZodEmail",(n,t)=>{HC.init(n,t),Ye.init(n,t)}),y0=$("ZodGUID",(n,t)=>{BC.init(n,t),Ye.init(n,t)}),au=$("ZodUUID",(n,t)=>{LC.init(n,t),Ye.init(n,t)}),mM=$("ZodURL",(n,t)=>{ZC.init(n,t),Ye.init(n,t)}),yM=$("ZodEmoji",(n,t)=>{$C.init(n,t),Ye.init(n,t)}),gM=$("ZodNanoID",(n,t)=>{qC.init(n,t),Ye.init(n,t)}),vM=$("ZodCUID",(n,t)=>{IC.init(n,t),Ye.init(n,t)}),SM=$("ZodCUID2",(n,t)=>{KC.init(n,t),Ye.init(n,t)}),bM=$("ZodULID",(n,t)=>{VC.init(n,t),Ye.init(n,t)}),wM=$("ZodXID",(n,t)=>{GC.init(n,t),Ye.init(n,t)}),_M=$("ZodKSUID",(n,t)=>{YC.init(n,t),Ye.init(n,t)}),EM=$("ZodIPv4",(n,t)=>{PC.init(n,t),Ye.init(n,t)}),xM=$("ZodIPv6",(n,t)=>{WC.init(n,t),Ye.init(n,t)}),TM=$("ZodCIDRv4",(n,t)=>{eA.init(n,t),Ye.init(n,t)}),OM=$("ZodCIDRv6",(n,t)=>{tA.init(n,t),Ye.init(n,t)}),zM=$("ZodBase64",(n,t)=>{nA.init(n,t),Ye.init(n,t)}),RM=$("ZodBase64URL",(n,t)=>{sA.init(n,t),Ye.init(n,t)}),CM=$("ZodE164",(n,t)=>{rA.init(n,t),Ye.init(n,t)}),AM=$("ZodJWT",(n,t)=>{lA.init(n,t),Ye.init(n,t)}),sb=$("ZodNumber",(n,t)=>{YS.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(r,l,u)=>z2(n,r,l),n.gt=(r,l)=>n.check(p0(r,l)),n.gte=(r,l)=>n.check(Nh(r,l)),n.min=(r,l)=>n.check(Nh(r,l)),n.lt=(r,l)=>n.check(d0(r,l)),n.lte=(r,l)=>n.check(kh(r,l)),n.max=(r,l)=>n.check(kh(r,l)),n.int=r=>n.check(g0(r)),n.safe=r=>n.check(g0(r)),n.positive=r=>n.check(p0(0,r)),n.nonnegative=r=>n.check(Nh(0,r)),n.negative=r=>n.check(d0(0,r)),n.nonpositive=r=>n.check(kh(0,r)),n.multipleOf=(r,l)=>n.check(m0(r,l)),n.step=(r,l)=>n.check(m0(r,l)),n.finite=()=>n;const s=n._zod.bag;n.minValue=Math.max(s.minimum??Number.NEGATIVE_INFINITY,s.exclusiveMinimum??Number.NEGATIVE_INFINITY)??null,n.maxValue=Math.min(s.maximum??Number.POSITIVE_INFINITY,s.exclusiveMaximum??Number.POSITIVE_INFINITY)??null,n.isInt=(s.format??"").includes("int")||Number.isSafeInteger(s.multipleOf??.5),n.isFinite=!0,n.format=s.format??null});function al(n){return i2(sb,n)}const MM=$("ZodNumberFormat",(n,t)=>{oA.init(n,t),sb.init(n,t)});function g0(n){return s2(MM,n)}const DM=$("ZodBoolean",(n,t)=>{uA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>R2(n,s,r)});function kM(n){return r2(DM,n)}const NM=$("ZodAny",(n,t)=>{cA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>A2()});function jM(){return a2(NM)}const UM=$("ZodUnknown",(n,t)=>{fA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>M2()});function v0(){return l2(UM)}const BM=$("ZodNever",(n,t)=>{hA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>C2(n,s,r)});function LM(n){return o2(BM,n)}const HM=$("ZodArray",(n,t)=>{dA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>j2(n,s,r,l),n.element=t.element,n.min=(s,r)=>n.check(Du(s,r)),n.nonempty=s=>n.check(Du(1,s)),n.max=(s,r)=>n.check(FS(s,r)),n.length=(s,r)=>n.check(PS(s,r)),n.unwrap=()=>n.element});function ZM(n,t){return b2(HM,n,t)}const $M=$("ZodObject",(n,t)=>{mA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>U2(n,s,r,l),ke(n,"shape",()=>t.shape),n.keyof=()=>Kd(Object.keys(n._zod.def.shape)),n.catchall=s=>n.clone({...n._zod.def,catchall:s}),n.passthrough=()=>n.clone({...n._zod.def,catchall:v0()}),n.loose=()=>n.clone({...n._zod.def,catchall:v0()}),n.strict=()=>n.clone({...n._zod.def,catchall:LM()}),n.strip=()=>n.clone({...n._zod.def,catchall:void 0}),n.extend=s=>NR(n,s),n.safeExtend=s=>jR(n,s),n.merge=s=>UR(n,s),n.pick=s=>DR(n,s),n.omit=s=>kR(n,s),n.partial=(...s)=>BR(rb,n,s[0]),n.required=(...s)=>LR(ab,n,s[0])});function Gu(n,t){const s={type:"object",shape:n??{},...ce(t)};return new $M(s)}const qM=$("ZodUnion",(n,t)=>{yA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>B2(n,s,r,l),n.options=t.options});function IM(n,t){return new qM({type:"union",options:n,...ce(t)})}const KM=$("ZodIntersection",(n,t)=>{gA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>L2(n,s,r,l)});function VM(n,t){return new KM({type:"intersection",left:n,right:t})}const od=$("ZodEnum",(n,t)=>{vA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(r,l,u)=>D2(n,r,l),n.enum=t.entries,n.options=Object.values(t.entries);const s=new Set(Object.keys(t.entries));n.extract=(r,l)=>{const u={};for(const c of r)if(s.has(c))u[c]=t.entries[c];else throw new Error(`Key ${c} not found in enum`);return new od({...t,checks:[],...ce(l),entries:u})},n.exclude=(r,l)=>{const u={...t.entries};for(const c of r)if(s.has(c))delete u[c];else throw new Error(`Key ${c} not found in enum`);return new od({...t,checks:[],...ce(l),entries:u})}});function Kd(n,t){const s=Array.isArray(n)?Object.fromEntries(n.map(r=>[r,r])):n;return new od({type:"enum",entries:s,...ce(t)})}const GM=$("ZodTransform",(n,t)=>{SA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>N2(n,s),n._zod.parse=(s,r)=>{if(r.direction==="backward")throw new MS(n.constructor.name);s.addIssue=u=>{if(typeof u=="string")s.issues.push(rl(u,s.value,t));else{const c=u;c.fatal&&(c.continue=!1),c.code??(c.code="custom"),c.input??(c.input=s.value),c.inst??(c.inst=n),s.issues.push(rl(c))}};const l=t.transform(s.value,s);return l instanceof Promise?l.then(u=>(s.value=u,s)):(s.value=l,s)}});function YM(n){return new GM({type:"transform",transform:n})}const rb=$("ZodOptional",(n,t)=>{XS.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>nb(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function S0(n){return new rb({type:"optional",innerType:n})}const JM=$("ZodExactOptional",(n,t)=>{bA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>nb(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function QM(n){return new JM({type:"optional",innerType:n})}const XM=$("ZodNullable",(n,t)=>{wA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>H2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function b0(n){return new XM({type:"nullable",innerType:n})}const FM=$("ZodDefault",(n,t)=>{_A.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>$2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType,n.removeDefault=n.unwrap});function PM(n,t){return new FM({type:"default",innerType:n,get defaultValue(){return typeof t=="function"?t():jS(t)}})}const WM=$("ZodPrefault",(n,t)=>{EA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>q2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function eD(n,t){return new WM({type:"prefault",innerType:n,get defaultValue(){return typeof t=="function"?t():jS(t)}})}const ab=$("ZodNonOptional",(n,t)=>{xA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>Z2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function tD(n,t){return new ab({type:"nonoptional",innerType:n,...ce(t)})}const nD=$("ZodCatch",(n,t)=>{TA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>I2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType,n.removeCatch=n.unwrap});function iD(n,t){return new nD({type:"catch",innerType:n,catchValue:typeof t=="function"?t:()=>t})}const sD=$("ZodPipe",(n,t)=>{OA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>K2(n,s,r,l),n.in=t.in,n.out=t.out});function w0(n,t){return new sD({type:"pipe",in:n,out:t})}const rD=$("ZodReadonly",(n,t)=>{zA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>V2(n,s,r,l),n.unwrap=()=>n._zod.def.innerType});function aD(n){return new rD({type:"readonly",innerType:n})}const lD=$("ZodCustom",(n,t)=>{RA.init(n,t),Fe.init(n,t),n._zod.processJSONSchema=(s,r,l)=>k2(n,s)});function oD(n,t={}){return w2(lD,n,t)}function uD(n){return _2(n)}const cD=Gu({id:Nt(),repo:Nt(),branch:Nt(),spec:Nt(),status:Kd(["running","needs_input","blocked","done"]),iteration:al(),started_at:Nt()}),fD=Gu({id:Nt(),session_id:Nt(),order:al(),content:Nt(),status:Kd(["pending","in_progress","completed"])}),hD=Gu({id:Nt(),session_id:Nt(),iteration:al(),sequence:al(),message:jM(),timestamp:Nt()}),dD=Gu({id:Nt(),session_id:Nt(),iteration:al(),question:Nt(),responded:kM(),response:Nt().nullable()}),pD=xR({sessions:{schema:cD,type:"session",primaryKey:"id"},tasks:{schema:fD,type:"task",primaryKey:"id"},claude_events:{schema:hD,type:"claude_event",primaryKey:"id"},input_requests:{schema:dD,type:"input_request",primaryKey:"id"}});function mD({token:n,onDisconnect:t}){return TR({streamOptions:{url:"/stream",headers:{Authorization:`Bearer ${n}`},onError:s=>{t&&t(s)}},state:pD})}function yD(n,t){return{submitInput:aS({onMutate:({requestId:r,response:l})=>{n.collections.input_requests.update(r,u=>{u.responded=!0,u.response=l})},mutationFn:async({requestId:r,response:l})=>{const u=await fetch("/input",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify({request_id:r,response:l})});if(!u.ok)throw new Error(`Failed to submit input: ${u.status}`)}})}}function gD({onLogin:n}){const[t,s]=j.useState(""),[r,l]=j.useState(null),[u,c]=j.useState(!1),d=async p=>{p.preventDefault(),c(!0),l(null);try{const m=await fetch("/auth",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:t})});if(!m.ok){m.status===401?l("Invalid password"):l(`Authentication failed: ${m.status}`),c(!1);return}const g=await m.json();n(g.token)}catch(m){l(m instanceof Error?m.message:"Connection failed"),c(!1)}};return Y.jsx("div",{className:"login",children:Y.jsxs("div",{className:"login-card",children:[Y.jsx("h1",{children:"Wisp"}),Y.jsx("p",{children:"Remote Access"}),Y.jsxs("form",{onSubmit:d,children:[Y.jsx("input",{type:"password",value:t,onChange:p=>s(p.target.value),placeholder:"Password",disabled:u,autoFocus:!0}),Y.jsx("button",{type:"submit",disabled:u||!t,children:u?"Connecting...":"Login"}),r&&Y.jsx("div",{className:"error-message",children:r})]})]})})}const jh=1;function ll(n,t=[]){const s=n&&typeof n=="object"&&typeof n.subscribeChanges=="function"&&typeof n.startSyncImmediate=="function"&&typeof n.id=="string",r=j.useRef(null),l=j.useRef(null),u=j.useRef(null),c=j.useRef(0),d=j.useRef(null),p=!r.current||s&&u.current!==n||!s&&(l.current===null||l.current.length!==t.length||l.current.some((b,E)=>b!==t[E]));if(p)if(s)n.startSyncImmediate(),r.current=n,u.current=n;else if(typeof n=="function"){const b=new ot,E=n(b);if(E==null)r.current=null;else if(E instanceof Ad)E.startSyncImmediate(),r.current=E;else if(E instanceof ot)r.current=Rh({query:n,startSync:!0,gcTime:jh});else if(E&&typeof E=="object")r.current=Rh({startSync:!0,gcTime:jh,...E});else throw new Error(`useLiveQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof E}`);l.current=[...t]}else r.current=Rh({startSync:!0,gcTime:jh,...n}),l.current=[...t];p&&(c.current=0,d.current=null);const m=j.useRef(null);(!m.current||p)&&(m.current=b=>{if(!r.current)return()=>{};const E=r.current.subscribeChanges(()=>{c.current+=1,b()});return r.current.status==="ready"&&(c.current+=1,b()),()=>{E.unsubscribe()}});const g=j.useRef(null);(!g.current||p)&&(g.current=()=>{const b=c.current,E=r.current;return(!d.current||d.current.version!==b||d.current.collection!==E)&&(d.current={collection:E,version:b}),d.current});const y=j.useSyncExternalStore(m.current,g.current),w=j.useRef(null),S=j.useRef(null);if(!w.current||w.current.version!==y.version||w.current.collection!==y.collection){if(!y.collection)S.current={state:void 0,data:void 0,collection:void 0,status:"disabled",isLoading:!1,isReady:!0,isIdle:!1,isError:!1,isCleanedUp:!1,isEnabled:!1};else{const b=Array.from(y.collection.entries()),O=y.collection.config.singleResult;let M=null,L=null;S.current={get state(){return M||(M=new Map(b)),M},get data(){return L||(L=b.map(([,N])=>N)),O?L[0]:L},collection:y.collection,status:y.collection.status,isLoading:y.collection.status==="loading",isReady:y.collection.status==="ready",isIdle:y.collection.status==="idle",isError:y.collection.status==="error",isCleanedUp:y.collection.status==="cleaned-up",isEnabled:!0}}w.current=y}return S.current}function vD({db:n}){var s,r;const t=ll(l=>l.from({sessions:n.collections.sessions}).orderBy(({sessions:u})=>u.started_at,"desc"));return Y.jsxs("div",{className:"dashboard",children:[Y.jsx("h2",{children:"Sessions"}),((s=t.data)==null?void 0:s.length)===0?Y.jsx("p",{className:"no-sessions",children:"No active sessions"}):Y.jsx("div",{className:"session-list",children:(r=t.data)==null?void 0:r.map(l=>Y.jsxs(gd,{to:`/session/${l.id}`,className:`session-card status-${l.status}`,children:[Y.jsx("div",{className:"session-repo",children:l.repo}),Y.jsx("div",{className:"session-branch",children:l.branch}),Y.jsxs("div",{className:"session-meta",children:[Y.jsx("span",{className:`status-badge ${l.status}`,children:l.status}),Y.jsxs("span",{className:"iteration",children:["Iteration ",l.iteration]})]})]},l.id))})]})}function SD({db:n,sessionId:t}){var r,l;const s=ll(u=>u.from({tasks:n.collections.tasks}).where(({tasks:c})=>Fi(c.session_id,t)).orderBy(({tasks:c})=>c.order,"asc"));return Y.jsxs("div",{className:"task-list",children:[Y.jsx("h3",{children:"Tasks"}),((r=s.data)==null?void 0:r.length)===0?Y.jsx("p",{className:"no-tasks",children:"No tasks yet"}):Y.jsx("ul",{children:(l=s.data)==null?void 0:l.map(u=>Y.jsxs("li",{className:`task status-${u.status}`,children:[Y.jsxs("span",{className:"task-status-icon",children:[u.status==="completed"&&"✓",u.status==="in_progress"&&"→",u.status==="pending"&&"○"]}),Y.jsx("span",{className:"task-content",children:u.content})]},u.id))})]})}function bD({db:n,sessionId:t}){var l,u;const s=j.useRef(null),r=ll(c=>c.from({events:n.collections.claude_events}).where(({events:d})=>Fi(d.session_id,t)).orderBy(({events:d})=>d.sequence,"asc"));return j.useEffect(()=>{var c;(c=s.current)==null||c.scrollIntoView({behavior:"smooth"})},[(l=r.data)==null?void 0:l.length]),Y.jsxs("div",{className:"output-log",children:[Y.jsx("h3",{children:"Output"}),Y.jsxs("div",{className:"output-content",children:[(u=r.data)==null?void 0:u.map(c=>Y.jsx(wD,{event:c},c.id)),Y.jsx("div",{ref:s})]})]})}function wD({event:n}){var s,r,l,u;const t=n.message;switch(t.type){case"assistant":return Y.jsx("div",{className:"event assistant",children:(r=(s=t.message)==null?void 0:s.content)==null?void 0:r.map((c,d)=>Y.jsx(_D,{block:c},d))});case"user":return Y.jsx("div",{className:"event tool-result",children:Y.jsx("pre",{children:JSON.stringify((l=t.message)==null?void 0:l.content,null,2)})});case"result":return Y.jsx("div",{className:"event result",children:t.subtype==="success"?`Done: ${t.num_turns} turns, $${((u=t.total_cost_usd)==null?void 0:u.toFixed(2))||"?"}`:`Error: ${t.subtype}`});case"system":return Y.jsxs("div",{className:"event system",children:["Session: ",t.session_id]});default:return null}}function _D({block:n}){return n.type==="text"?Y.jsx("div",{className:"content-text",children:n.text}):n.type==="tool_use"?Y.jsxs("details",{className:"content-tool-use",children:[Y.jsxs("summary",{children:["[",n.name,"]"]}),Y.jsx("pre",{children:JSON.stringify(n.input,null,2)})]}):null}function ED({actions:n,request:t}){const[s,r]=j.useState("");j.useEffect(()=>{!t.responded&&document.hidden&&xD().then(()=>{TD("Wisp needs input",t.question)})},[t.id,t.responded,t.question]);const l=u=>{u.preventDefault(),s.trim()&&(n.submitInput({requestId:t.id,response:s}),r(""))};return t.responded?Y.jsxs("div",{className:"input-prompt responded",children:[Y.jsx("p",{className:"question",children:t.question}),Y.jsxs("p",{className:"response",children:["Responded: ",t.response]})]}):Y.jsx("div",{className:"input-prompt",children:Y.jsxs("form",{onSubmit:l,children:[Y.jsx("p",{className:"question",children:t.question}),Y.jsxs("div",{className:"input-row",children:[Y.jsx("input",{type:"text",value:s,onChange:u=>r(u.target.value),placeholder:"Type your response...",autoFocus:!0}),Y.jsx("button",{type:"submit",disabled:!s.trim(),children:"Send"})]})]})})}async function xD(){return"Notification"in window?Notification.permission==="granted"?!0:Notification.permission!=="denied"?await Notification.requestPermission()==="granted":!1:!1}function TD(n,t){if(Notification.permission==="granted"){const s=new Notification(n,{body:t});s.onclick=()=>{window.focus(),s.close()}}}function OD({db:n,actions:t}){var d,p;const{id:s}=TE(),r=ll(m=>m.from({sessions:n.collections.sessions}).where(({sessions:g})=>Fi(g.id,s)).limit(1)),l=ll(m=>m.from({requests:n.collections.input_requests}).where(({requests:g})=>Fi(g.session_id,s)).where(({requests:g})=>Fi(g.responded,!1)).orderBy(({requests:g})=>g.iteration,"desc").limit(1)),u=(d=r.data)==null?void 0:d[0],c=(p=l.data)==null?void 0:p[0];return s?u?Y.jsxs("div",{className:"session",children:[Y.jsxs("header",{className:"session-header",children:[Y.jsxs("div",{className:"session-info",children:[Y.jsx("h2",{children:u.repo}),Y.jsx("p",{className:"branch",children:u.branch})]}),Y.jsxs("div",{className:"session-status",children:[Y.jsx("span",{className:`status-badge ${u.status}`,children:u.status}),Y.jsxs("span",{className:"iteration",children:["Iteration ",u.iteration]})]})]}),Y.jsxs("div",{className:"session-content",children:[Y.jsx("aside",{className:"session-sidebar",children:Y.jsx(SD,{db:n,sessionId:s})}),Y.jsxs("section",{className:"session-main",children:[c&&Y.jsx(ED,{db:n,actions:t,request:c}),Y.jsx(bD,{db:n,sessionId:s})]})]})]}):Y.jsx("div",{className:"session-loading",children:Y.jsx("p",{children:"Loading session..."})}):Y.jsx(L0,{to:"/",replace:!0})}function zD({onReconnect:n,error:t}){return Y.jsx("div",{className:"disconnected",children:Y.jsxs("div",{className:"disconnected-content",children:[Y.jsx("div",{className:"disconnected-icon",children:"⚠"}),Y.jsx("h1",{children:"Disconnected"}),Y.jsx("p",{className:"disconnected-message",children:t||"The connection to the wisp server was lost."}),Y.jsx("p",{className:"disconnected-hint",children:"The wisp session may have ended or the server may be unreachable."}),Y.jsx("button",{onClick:n,className:"reconnect-btn",children:"Reconnect"})]})})}function RD(){const[n,t]=j.useState(null),[s,r]=j.useState(null),[l,u]=j.useState("disconnected"),[c,d]=j.useState(null),[p,m]=j.useState(0),g=j.useCallback(()=>{u("disconnected"),r(null),d(null),m(S=>S+1)},[]);j.useEffect(()=>{if(n){u("connecting"),d(null);let S=!1;const b=mD({token:n,onDisconnect:E=>{S||(d(E.message),u("disconnected"),r(null))}});return b.preload().then(()=>{S||(r(b),u("connected"))}).catch(E=>{S||(d(E.message),u("error"),t(null))}),()=>{S=!0,b.close()}}else r(null),u("disconnected")},[n,p]);const y=()=>{t(null),r(null)};if(!n)return Y.jsx(gD,{onLogin:t});if(l==="connecting")return Y.jsx("div",{className:"loading",children:Y.jsx("p",{children:"Connecting..."})});if(l==="error")return Y.jsxs("div",{className:"error",children:[Y.jsxs("p",{children:["Connection error: ",c]}),Y.jsx("button",{onClick:()=>t(null),children:"Try Again"})]});if(l==="disconnected"&&n&&!s)return Y.jsx(zD,{onReconnect:g,error:c||void 0});if(!s)return null;const w=yD(s,n);return Y.jsx(c1,{children:Y.jsxs("div",{className:"app",children:[Y.jsxs("header",{className:"app-header",children:[Y.jsx("h1",{children:"Wisp"}),Y.jsx("button",{onClick:y,className:"logout-btn",children:"Logout"})]}),Y.jsx("main",{children:Y.jsxs(ZE,{children:[Y.jsx(lu,{path:"/",element:Y.jsx(vD,{db:s})}),Y.jsx(lu,{path:"/session/:id",element:Y.jsx(OD,{db:s,actions:w})}),Y.jsx(lu,{path:"*",element:Y.jsx(L0,{to:"/",replace:!0})})]})})]})})}q_.createRoot(document.getElementById("root")).render(Y.jsx(j.StrictMode,{children:Y.jsx(RD,{})})); diff --git a/web/dist/index.html b/web/dist/index.html index ec5a9f6..f95a2f3 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -5,8 +5,8 @@ Wisp - Remote Access - - + +
diff --git a/web/src/App.tsx b/web/src/App.tsx index 9503b4c..2244c6f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,37 +1,66 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { createDb, type WispDB } from './db/store' import { createActions } from './db/actions' import { Login } from './components/Login' import { Dashboard } from './components/Dashboard' import { Session } from './components/Session' +import { Disconnected } from './components/Disconnected' + +type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error' export function App() { const [token, setToken] = useState(null) const [db, setDb] = useState(null) - const [loading, setLoading] = useState(false) + const [connectionState, setConnectionState] = useState('disconnected') const [error, setError] = useState(null) + const [reconnectKey, setReconnectKey] = useState(0) + + const handleReconnect = useCallback(() => { + setConnectionState('disconnected') + setDb(null) + setError(null) + setReconnectKey(k => k + 1) + }, []) useEffect(() => { if (token) { - setLoading(true) + setConnectionState('connecting') setError(null) - const database = createDb(token) + let cancelled = false + + const database = createDb({ + token, + onDisconnect: (err) => { + if (!cancelled) { + setError(err.message) + setConnectionState('disconnected') + setDb(null) + } + }, + }) + database.preload() .then(() => { + if (cancelled) return setDb(database) - setLoading(false) + setConnectionState('connected') }) .catch((err: Error) => { + if (cancelled) return setError(err.message) - setLoading(false) + setConnectionState('error') setToken(null) }) - return () => database.close() + return () => { + cancelled = true + database.close() + } } else { setDb(null) + setConnectionState('disconnected') } - }, [token]) + }, [token, reconnectKey]) const handleLogout = () => { setToken(null) @@ -42,7 +71,7 @@ export function App() { return } - if (loading) { + if (connectionState === 'connecting') { return (

Connecting...

@@ -50,7 +79,7 @@ export function App() { ) } - if (error) { + if (connectionState === 'error') { return (

Connection error: {error}

@@ -59,6 +88,11 @@ export function App() { ) } + // Show disconnected page when connection is lost after being connected + if (connectionState === 'disconnected' && token && !db) { + return + } + if (!db) { return null } diff --git a/web/src/components/Disconnected.tsx b/web/src/components/Disconnected.tsx new file mode 100644 index 0000000..424fda6 --- /dev/null +++ b/web/src/components/Disconnected.tsx @@ -0,0 +1,24 @@ +interface DisconnectedProps { + onReconnect: () => void + error?: string +} + +export function Disconnected({ onReconnect, error }: DisconnectedProps) { + return ( +
+
+
+

Disconnected

+

+ {error || 'The connection to the wisp server was lost.'} +

+

+ The wisp session may have ended or the server may be unreachable. +

+ +
+
+ ) +} diff --git a/web/src/db/store.ts b/web/src/db/store.ts index 49f0228..c03aa4a 100644 --- a/web/src/db/store.ts +++ b/web/src/db/store.ts @@ -4,11 +4,25 @@ import { stateSchema } from './schema' export type { SDKMessage } -export function createDb(token: string) { +export interface CreateDbOptions { + token: string + onDisconnect?: (error: Error) => void +} + +export function createDb({ token, onDisconnect }: CreateDbOptions) { return createStreamDB({ streamOptions: { url: '/stream', headers: { Authorization: `Bearer ${token}` }, + onError: (error) => { + // For connection errors (server gone), trigger disconnect + // Don't return anything to let the error propagate and close the stream + if (onDisconnect) { + onDisconnect(error) + } + // Return undefined to propagate the error and stop the stream + return undefined + }, }, state: stateSchema, }) diff --git a/web/src/styles/main.css b/web/src/styles/main.css index 4ddd121..cbdefa2 100644 --- a/web/src/styles/main.css +++ b/web/src/styles/main.css @@ -511,3 +511,60 @@ main { margin: 0; color: var(--color-success); } + +/* Disconnected page */ +.disconnected { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: var(--color-bg); +} + +.disconnected-content { + background: var(--color-surface); + padding: 3rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + text-align: center; + max-width: 400px; + width: 100%; + margin: 1rem; +} + +.disconnected-icon { + font-size: 4rem; + margin-bottom: 1rem; + color: var(--color-warning); +} + +.disconnected h1 { + margin: 0 0 1rem; + color: var(--color-text); +} + +.disconnected-message { + margin: 0 0 0.5rem; + color: var(--color-text); +} + +.disconnected-hint { + margin: 0 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.reconnect-btn { + padding: 0.75rem 2rem; + border: none; + border-radius: var(--radius); + background: var(--color-primary); + color: white; + font-size: 1rem; + cursor: pointer; + transition: background 0.15s; +} + +.reconnect-btn:hover { + background: var(--color-primary-hover); +} From 4ae8e3ddb3bb019eeffaaa42a7428c22a07dcb5f Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 22:58:10 +0000 Subject: [PATCH 11/19] feat(server): implement first-response-wins for concurrent input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proper handling for concurrent TUI and web client input: - Add inputMu mutex for input-specific synchronization - Track responded inputs in respondedInputs map - Return 409 Conflict if input already responded - Add MarkInputResponded() for TUI to mark inputs as responded - Add IsInputResponded() to check response status - Broadcast input request updates when responded - Update loop to mark inputs as responded when TUI provides input 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/loop/loop.go | 4 + internal/server/server.go | 71 +++++++++++-- internal/server/server_test.go | 178 +++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 9 deletions(-) diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 713517c..63f494e 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -674,6 +674,10 @@ func (l *Loop) handleNeedsInput(ctx context.Context, st *state.State) Result { case action := <-l.tui.Actions(): switch action.Action { case tui.ActionSubmitInput: + // Mark as responded in server first (for first-response-wins) + if l.server != nil && requestID != "" { + l.server.MarkInputResponded(requestID) + } // Write response to Sprite if err := l.sync.WriteResponseToSprite(ctx, l.session.SpriteName, action.Input); err != nil { return Result{ diff --git a/internal/server/server.go b/internal/server/server.go index c2b2d84..329e1d8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -40,8 +40,10 @@ type Server struct { // Durable Streams streams *StreamManager - // Pending user inputs from web client - pendingInputs map[string]string // request_id -> response + // Input handling + inputMu sync.Mutex + pendingInputs map[string]string // request_id -> response + respondedInputs map[string]bool // request_id -> true if already responded // Static assets filesystem assets fs.FS @@ -501,6 +503,8 @@ func formatJSONResponse(messages []store.Message) []byte { } // handleInput handles POST /input for user responses. +// Implements first-response-wins: if the request has already been responded to +// (either from web or TUI), subsequent responses are rejected. func (s *Server) handleInput(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -528,15 +532,41 @@ func (s *Server) handleInput(w http.ResponseWriter, r *http.Request) { return } - // Store the input response for the session to handle - // Note: Actual implementation of writing to response.json will be done - // in the loop integration task. For now, we just acknowledge receipt. - s.mu.Lock() + // Use inputMu for input-specific operations (first-response-wins) + s.inputMu.Lock() + + // Check if this request has already been responded to + if s.respondedInputs != nil && s.respondedInputs[req.RequestID] { + s.inputMu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, `{"status":"already_responded"}`) + return + } + + // Initialize maps if needed if s.pendingInputs == nil { s.pendingInputs = make(map[string]string) } + if s.respondedInputs == nil { + s.respondedInputs = make(map[string]bool) + } + + // Mark as responded and store the response + s.respondedInputs[req.RequestID] = true s.pendingInputs[req.RequestID] = req.Response - s.mu.Unlock() + s.inputMu.Unlock() + + // Broadcast that this input request has been responded to + // This allows web clients to see the updated state immediately + if s.streams != nil { + inputReq := &InputRequest{ + ID: req.RequestID, + Responded: true, + Response: &req.Response, + } + s.streams.BroadcastInputRequest(inputReq) + } // Return success w.Header().Set("Content-Type", "application/json") @@ -545,9 +575,10 @@ func (s *Server) handleInput(w http.ResponseWriter, r *http.Request) { } // GetPendingInput retrieves and removes a pending input response. +// This is called by the loop when polling for web client input. func (s *Server) GetPendingInput(requestID string) (string, bool) { - s.mu.Lock() - defer s.mu.Unlock() + s.inputMu.Lock() + defer s.inputMu.Unlock() if s.pendingInputs == nil { return "", false } @@ -558,6 +589,28 @@ func (s *Server) GetPendingInput(requestID string) (string, bool) { return response, ok } +// MarkInputResponded marks an input request as responded. +// This is called by the loop when the TUI provides input, to prevent +// subsequent web client responses from being accepted. +func (s *Server) MarkInputResponded(requestID string) { + s.inputMu.Lock() + defer s.inputMu.Unlock() + if s.respondedInputs == nil { + s.respondedInputs = make(map[string]bool) + } + s.respondedInputs[requestID] = true +} + +// IsInputResponded checks if an input request has already been responded to. +func (s *Server) IsInputResponded(requestID string) bool { + s.inputMu.Lock() + defer s.inputMu.Unlock() + if s.respondedInputs == nil { + return false + } + return s.respondedInputs[requestID] +} + // handleStatic handles GET requests for serving static web assets. // It serves files from the embedded or development assets filesystem. // For SPA support, requests for non-existent paths return index.html. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3c06b14..5f1bda0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -901,6 +901,184 @@ func TestPendingInputConcurrency(t *testing.T) { wg.Wait() } +func TestInputFirstResponseWins(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("first response succeeds", func(t *testing.T) { + // First response should succeed + body := `{"request_id": "first-wins-123", "response": "first response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + var result struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Status != "received" { + t.Errorf("expected status 'received', got '%s'", result.Status) + } + }) + + t.Run("second response rejected with 409 Conflict", func(t *testing.T) { + // Second response to same request_id should fail with 409 Conflict + body := `{"request_id": "first-wins-123", "response": "second response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected status %d (Conflict), got %d", http.StatusConflict, resp.StatusCode) + } + + var result struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Status != "already_responded" { + t.Errorf("expected status 'already_responded', got '%s'", result.Status) + } + }) +} + +func TestInputMarkResponded(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("MarkInputResponded blocks subsequent web input", func(t *testing.T) { + reqID := "tui-first-123" + + // Simulate TUI responding first by calling MarkInputResponded + server.MarkInputResponded(reqID) + + // Now try to respond via web - should fail with 409 + body := fmt.Sprintf(`{"request_id": "%s", "response": "web response"}`, reqID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusConflict { + t.Errorf("expected status %d (Conflict), got %d", http.StatusConflict, resp.StatusCode) + } + }) + + t.Run("IsInputResponded returns correct state", func(t *testing.T) { + // New request_id should not be responded + if server.IsInputResponded("new-request-456") { + t.Error("expected new request to not be responded") + } + + // After marking, it should be responded + server.MarkInputResponded("new-request-456") + if !server.IsInputResponded("new-request-456") { + t.Error("expected marked request to be responded") + } + }) +} + +func TestInputBroadcastsUpdate(t *testing.T) { + server := createTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + + time.Sleep(50 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + // Submit input + body := `{"request_id": "broadcast-test-123", "response": "broadcast response"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode) + } + + // Verify the input request was broadcast as responded + _, _, inputRequests := server.Streams().GetCurrentState() + + var found *InputRequest + for _, req := range inputRequests { + if req.ID == "broadcast-test-123" { + found = req + break + } + } + + if found == nil { + t.Fatal("expected input request to be broadcast") + } + + if !found.Responded { + t.Error("expected input request to be marked as responded") + } + + if found.Response == nil || *found.Response != "broadcast response" { + t.Error("expected response to be set correctly") + } +} + // Tests for static asset serving func TestHandleStatic(t *testing.T) { From 24c61e64bc57a99ffdda556c2629e818c4f658d3 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 23:05:43 +0000 Subject: [PATCH 12/19] test(server): add integration tests for web server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive integration tests with //go:build integration tag covering: - Auth flow (correct/incorrect password, token validation) - State sync on connect (initial sync, incremental updates) - Real-time updates via stream (long-poll, rapid updates, Claude events) - Input submission (store and broadcast, validation) - Concurrent TUI/web input handling (first-response-wins) - SSE streaming mode - Server lifecycle (start, stop, graceful shutdown) - End-to-end flow test Run with: go test -tags=integration ./internal/server/... 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/server/server_integration_test.go | 1068 ++++++++++++++++++++ 1 file changed, 1068 insertions(+) create mode 100644 internal/server/server_integration_test.go diff --git a/internal/server/server_integration_test.go b/internal/server/server_integration_test.go new file mode 100644 index 0000000..d38775e --- /dev/null +++ b/internal/server/server_integration_test.go @@ -0,0 +1,1068 @@ +//go:build integration + +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thruflo/wisp/internal/auth" +) + +// Integration tests for the web server. +// Run with: go test -tags=integration ./internal/server/... + +// TestIntegrationAuthFlow tests the complete authentication flow including +// correct password, wrong password, and token-based access to protected endpoints. +func TestIntegrationAuthFlow(t *testing.T) { + t.Parallel() + + // Create server with known password + hash, err := auth.HashPassword(testPassword) + require.NoError(t, err) + + server, err := NewServer(&Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Start server + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + require.NotEmpty(t, addr) + + t.Run("correct_password_returns_valid_token", func(t *testing.T) { + // Authenticate with correct password + form := url.Values{"password": {testPassword}} + resp, err := http.PostForm("http://"+addr+"/auth", form) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var authResp struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&authResp)) + assert.NotEmpty(t, authResp.Token) + assert.Len(t, authResp.Token, 64) // 32 bytes hex encoded + + // Token should work for protected endpoints + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+authResp.Token) + streamResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer streamResp.Body.Close() + + assert.Equal(t, http.StatusOK, streamResp.StatusCode) + }) + + t.Run("incorrect_password_returns_401", func(t *testing.T) { + form := url.Values{"password": {"wrong-password"}} + resp, err := http.PostForm("http://"+addr+"/auth", form) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("empty_password_returns_400", func(t *testing.T) { + form := url.Values{"password": {""}} + resp, err := http.PostForm("http://"+addr+"/auth", form) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("invalid_token_returns_401", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer invalid-token-12345") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("no_auth_header_returns_401", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("multiple_tokens_all_valid", func(t *testing.T) { + // Get multiple tokens + var tokens []string + for i := 0; i < 3; i++ { + form := url.Values{"password": {testPassword}} + resp, err := http.PostForm("http://"+addr+"/auth", form) + require.NoError(t, err) + + var authResp struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&authResp)) + resp.Body.Close() + tokens = append(tokens, authResp.Token) + } + + // All tokens should be unique + assert.NotEqual(t, tokens[0], tokens[1]) + assert.NotEqual(t, tokens[1], tokens[2]) + + // All tokens should work + for _, token := range tokens { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + } + }) +} + +// TestIntegrationStateSyncOnConnect tests that clients receive current state +// when connecting to the stream endpoint. +func TestIntegrationStateSyncOnConnect(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + // Broadcast state before client connects + session := &Session{ + ID: "test-session-1", + Repo: "owner/repo", + Branch: "wisp/feature-x", + Spec: "docs/rfc.md", + Status: SessionStatusRunning, + Iteration: 5, + StartedAt: "2024-01-15T10:00:00Z", + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + tasks := []*Task{ + {ID: "task-1", SessionID: "test-session-1", Order: 0, Content: "Setup project", Status: TaskStatusCompleted}, + {ID: "task-2", SessionID: "test-session-1", Order: 1, Content: "Implement feature", Status: TaskStatusInProgress}, + {ID: "task-3", SessionID: "test-session-1", Order: 2, Content: "Write tests", Status: TaskStatusPending}, + } + for _, task := range tasks { + require.NoError(t, server.Streams().BroadcastTask(task)) + } + + inputReq := &InputRequest{ + ID: "input-1", + SessionID: "test-session-1", + Iteration: 5, + Question: "How should I proceed?", + Responded: false, + Response: nil, + } + require.NoError(t, server.Streams().BroadcastInputRequest(inputReq)) + + // Client connects and should receive all state from beginning + t.Run("new_client_receives_all_state", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Stream-Next-Offset")) + // Note: Stream-Up-To-Date may or may not be set depending on whether + // we've caught up with the tail. The important thing is we get all messages. + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + + // Should have 5 messages: 1 session + 3 tasks + 1 input request + assert.Len(t, messages, 5) + + // Verify session + sessionMsg := messages[0] + assert.Equal(t, MessageTypeSession, sessionMsg.Type) + + // Verify tasks + for i := 1; i <= 3; i++ { + assert.Equal(t, MessageTypeTask, messages[i].Type) + } + + // Verify input request + assert.Equal(t, MessageTypeInputRequest, messages[4].Type) + }) + + t.Run("client_with_offset_receives_only_new_messages", func(t *testing.T) { + // First request to get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Broadcast new message + newTask := &Task{ + ID: "task-4", + SessionID: "test-session-1", + Order: 3, + Content: "Deploy", + Status: TaskStatusPending, + } + require.NoError(t, server.Streams().BroadcastTask(newTask)) + + // Request with offset should only get new message + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + + assert.Len(t, messages, 1) + assert.Equal(t, MessageTypeTask, messages[0].Type) + }) +} + +// TestIntegrationRealTimeUpdatesViaStream tests that updates are received +// in real-time through the stream endpoint using long-polling. +func TestIntegrationRealTimeUpdatesViaStream(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("long_poll_receives_updates", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Start long-poll in goroutine + resultCh := make(chan []StreamMessage, 1) + errCh := make(chan error, 1) + + go func() { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=long-poll&offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + errCh <- err + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + errCh <- err + return + } + + var messages []StreamMessage + if err := json.Unmarshal(body, &messages); err != nil { + errCh <- err + return + } + resultCh <- messages + }() + + // Wait for long-poll to establish, then broadcast + time.Sleep(200 * time.Millisecond) + + session := &Session{ + ID: "realtime-session", + Repo: "owner/repo", + Branch: "wisp/realtime", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: "2024-01-15T12:00:00Z", + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + // Wait for result + select { + case messages := <-resultCh: + require.Len(t, messages, 1) + assert.Equal(t, MessageTypeSession, messages[0].Type) + case err := <-errCh: + t.Fatalf("long-poll error: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("long-poll did not receive message in time") + } + }) + + t.Run("multiple_rapid_updates_delivered", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Broadcast multiple messages rapidly + for i := 0; i < 10; i++ { + task := &Task{ + ID: fmt.Sprintf("rapid-task-%d", i), + SessionID: "rapid-session", + Order: i, + Content: fmt.Sprintf("Task %d", i), + Status: TaskStatusPending, + } + require.NoError(t, server.Streams().BroadcastTask(task)) + } + + // Request should get all messages + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 10) + }) + + t.Run("claude_events_delivered_in_order", func(t *testing.T) { + // Get current offset + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Broadcast Claude events + for i := 0; i < 5; i++ { + event := &ClaudeEvent{ + ID: fmt.Sprintf("sess-1-%d", i), + SessionID: "sess", + Iteration: 1, + Sequence: i, + Message: map[string]any{ + "type": "assistant", + "message": map[string]any{ + "content": []any{ + map[string]any{ + "type": "text", + "text": fmt.Sprintf("Message %d", i), + }, + }, + }, + }, + Timestamp: time.Now().Format(time.RFC3339), + } + require.NoError(t, server.Streams().BroadcastClaudeEvent(event)) + } + + // Request should get all events in order + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 5) + + for i, msg := range messages { + assert.Equal(t, MessageTypeClaudeEvent, msg.Type) + dataBytes, _ := json.Marshal(msg.Data) + var evt ClaudeEvent + json.Unmarshal(dataBytes, &evt) + assert.Equal(t, i, evt.Sequence) + } + }) +} + +// TestIntegrationInputSubmission tests the input submission flow including +// storing responses and broadcasting updates. +func TestIntegrationInputSubmission(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("submit_input_stores_and_broadcasts", func(t *testing.T) { + // Get current offset to track broadcasts + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + resp.Body.Close() + + // Submit input + body := `{"request_id": "submit-test-1", "response": "yes, proceed"}` + req, _ = http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result struct { + Status string `json:"status"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, "received", result.Status) + + // Verify response was stored + response, ok := server.GetPendingInput("submit-test-1") + assert.True(t, ok) + assert.Equal(t, "yes, proceed", response) + + // Verify broadcast was sent + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(bodyBytes, &messages)) + require.Len(t, messages, 1) + assert.Equal(t, MessageTypeInputRequest, messages[0].Type) + + dataBytes, _ := json.Marshal(messages[0].Data) + var inputReq InputRequest + json.Unmarshal(dataBytes, &inputReq) + assert.Equal(t, "submit-test-1", inputReq.ID) + assert.True(t, inputReq.Responded) + require.NotNil(t, inputReq.Response) + assert.Equal(t, "yes, proceed", *inputReq.Response) + }) + + t.Run("submit_with_empty_response", func(t *testing.T) { + body := `{"request_id": "empty-response-1", "response": ""}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Empty string response should still be stored + response, ok := server.GetPendingInput("empty-response-1") + assert.True(t, ok) + assert.Equal(t, "", response) + }) + + t.Run("submit_without_request_id_fails", func(t *testing.T) { + body := `{"response": "yes"}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("submit_invalid_json_fails", func(t *testing.T) { + body := `{invalid json}` + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +// TestIntegrationConcurrentInputHandling tests the first-response-wins semantics +// for concurrent input from TUI and web clients. +func TestIntegrationConcurrentInputHandling(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("web_then_web_second_rejected", func(t *testing.T) { + requestID := "concurrent-web-web-1" + + // First web response + body := fmt.Sprintf(`{"request_id": "%s", "response": "first web response"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + + // Second web response should be rejected + body = fmt.Sprintf(`{"request_id": "%s", "response": "second web response"}`, requestID) + req, _ = http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + + var result struct { + Status string `json:"status"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, "already_responded", result.Status) + }) + + t.Run("tui_then_web_rejected", func(t *testing.T) { + requestID := "concurrent-tui-web-1" + + // Simulate TUI responding first + server.MarkInputResponded(requestID) + + // Web response should be rejected + body := fmt.Sprintf(`{"request_id": "%s", "response": "web response after tui"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + + var result struct { + Status string `json:"status"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Equal(t, "already_responded", result.Status) + }) + + t.Run("concurrent_web_requests_one_wins", func(t *testing.T) { + requestID := "concurrent-race-1" + + // Launch multiple concurrent requests + var wg sync.WaitGroup + var successCount int32 + var conflictCount int32 + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + + body := fmt.Sprintf(`{"request_id": "%s", "response": "response %d"}`, requestID, n) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + atomic.AddInt32(&successCount, 1) + case http.StatusConflict: + atomic.AddInt32(&conflictCount, 1) + } + }(i) + } + + wg.Wait() + + // Exactly one should succeed, rest should get conflict + assert.Equal(t, int32(1), successCount, "exactly one request should succeed") + assert.Equal(t, int32(9), conflictCount, "rest should get conflict") + }) + + t.Run("isInputResponded_reflects_state", func(t *testing.T) { + requestID := "state-check-1" + + // Initially not responded + assert.False(t, server.IsInputResponded(requestID)) + + // After web submission + body := fmt.Sprintf(`{"request_id": "%s", "response": "test"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + + // Now should be responded + assert.True(t, server.IsInputResponded(requestID)) + }) + + t.Run("tui_check_before_response_prevents_conflict", func(t *testing.T) { + requestID := "tui-check-1" + + // TUI checks if already responded (simulating loop behavior) + assert.False(t, server.IsInputResponded(requestID)) + + // Web submits first + body := fmt.Sprintf(`{"request_id": "%s", "response": "web wins"}`, requestID) + req, _ := http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + + // TUI checks again and sees it's responded + assert.True(t, server.IsInputResponded(requestID)) + + // TUI can get the response + response, ok := server.GetPendingInput(requestID) + assert.True(t, ok) + assert.Equal(t, "web wins", response) + }) +} + +// TestIntegrationSSEStream tests Server-Sent Events streaming mode. +func TestIntegrationSSEStream(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + t.Run("sse_receives_initial_control_event", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=sse&offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "text/event-stream") + + // Read first event (should be control) + buf := make([]byte, 4096) + n, err := resp.Body.Read(buf) + // err might be timeout or nil, both ok + if err != nil && err != io.EOF { + // timeout is expected + t.Logf("Read completed with: %v", err) + } + + body := string(buf[:n]) + assert.Contains(t, body, "event: control") + assert.Contains(t, body, "streamNextOffset") + }) + + t.Run("sse_receives_data_events", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=sse&offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + + // Use a context with cancel for controlled shutdown + reqCtx, reqCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer reqCancel() + req = req.WithContext(reqCtx) + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Read in goroutine while we broadcast + dataCh := make(chan string, 10) + go func() { + buf := make([]byte, 8192) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + dataCh <- string(buf[:n]) + } + if err != nil { + close(dataCh) + return + } + } + }() + + // Wait for initial control event + time.Sleep(200 * time.Millisecond) + + // Broadcast a message + session := &Session{ + ID: "sse-test-session", + Repo: "test/repo", + Branch: "sse-test", + Spec: "spec.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + // Collect data + var allData strings.Builder + timeout := time.After(3 * time.Second) + collect: + for { + select { + case data, ok := <-dataCh: + if !ok { + break collect + } + allData.WriteString(data) + if strings.Contains(data, "sse-test-session") { + break collect + } + case <-timeout: + break collect + } + } + + assert.Contains(t, allData.String(), "event: data") + assert.Contains(t, allData.String(), "sse-test-session") + }) +} + +// TestIntegrationServerLifecycle tests server start, stop, and restart behavior. +func TestIntegrationServerLifecycle(t *testing.T) { + t.Parallel() + + hash, err := auth.HashPassword(testPassword) + require.NoError(t, err) + + t.Run("start_stop_restart", func(t *testing.T) { + server, err := NewServer(&Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start + errCh := make(chan error, 1) + go func() { + errCh <- server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + + addr := server.ListenAddr() + require.NotEmpty(t, addr) + + // Verify working + form := url.Values{"password": {testPassword}} + resp, err := http.PostForm("http://"+addr+"/auth", form) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + + // Stop via server.Stop() for more reliable shutdown + err = server.Stop() + require.NoError(t, err) + + // Wait for shutdown to complete + select { + case err := <-errCh: + // Should exit cleanly + assert.NoError(t, err) + case <-time.After(3 * time.Second): + t.Fatal("server did not stop in time") + } + + // Should be stopped - connection should be refused + client := &http.Client{Timeout: 1 * time.Second} + _, err = client.PostForm("http://"+addr+"/auth", form) + assert.Error(t, err, "expected connection refused after server stop") + }) + + t.Run("graceful_shutdown_with_active_connections", func(t *testing.T) { + server, err := NewServer(&Config{ + Port: 0, + PasswordHash: hash, + }) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + + addr := server.ListenAddr() + token := getAuthToken(t, addr) + + // Start a long-poll connection + go func() { + req, _ := http.NewRequest("GET", "http://"+addr+"/stream?live=long-poll&offset=now", nil) + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 30 * time.Second} + client.Do(req) + }() + + time.Sleep(100 * time.Millisecond) + + // Stop should complete without hanging + done := make(chan bool) + go func() { + server.Stop() + done <- true + }() + + select { + case <-done: + // Good + case <-time.After(6 * time.Second): + t.Fatal("graceful shutdown took too long") + } + }) +} + +// TestIntegrationEndToEndFlow tests a complete realistic flow: +// authenticate, sync state, receive updates, submit input. +func TestIntegrationEndToEndFlow(t *testing.T) { + t.Parallel() + + server := createTestServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + go func() { + server.Start(ctx) + }() + time.Sleep(100 * time.Millisecond) + defer server.Stop() + + addr := server.ListenAddr() + + // Step 1: Authenticate + form := url.Values{"password": {testPassword}} + resp, err := http.PostForm("http://"+addr+"/auth", form) + require.NoError(t, err) + var authResp struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&authResp)) + resp.Body.Close() + token := authResp.Token + + // Step 2: Initial state sync - should be empty + req, _ := http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + assert.Equal(t, "[]", string(body)) + + // Step 3: Simulate session starting (server-side broadcast) + session := &Session{ + ID: "e2e-session", + Repo: "owner/repo", + Branch: "wisp/e2e-test", + Spec: "docs/rfc.md", + Status: SessionStatusRunning, + Iteration: 1, + StartedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, server.Streams().BroadcastSession(session)) + + task := &Task{ + ID: "e2e-task-1", + SessionID: "e2e-session", + Order: 0, + Content: "Implement feature", + Status: TaskStatusInProgress, + } + require.NoError(t, server.Streams().BroadcastTask(task)) + + // Step 4: Get updates + req, _ = http.NewRequest("GET", "http://"+addr+"/stream", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + offset := resp.Header.Get("Stream-Next-Offset") + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + var messages []StreamMessage + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 2) + + // Step 5: NEEDS_INPUT broadcast + session.Status = SessionStatusNeedsInput + session.Iteration = 2 + require.NoError(t, server.Streams().BroadcastSession(session)) + + inputReq := &InputRequest{ + ID: "e2e-input-1", + SessionID: "e2e-session", + Iteration: 2, + Question: "Should I continue with approach A or B?", + Responded: false, + Response: nil, + } + require.NoError(t, server.Streams().BroadcastInputRequest(inputReq)) + + // Step 6: Client receives NEEDS_INPUT update + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + offset = resp.Header.Get("Stream-Next-Offset") + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + require.NoError(t, json.Unmarshal(body, &messages)) + assert.Len(t, messages, 2) // session update + input request + + // Find input request + var foundInput bool + for _, msg := range messages { + if msg.Type == MessageTypeInputRequest { + foundInput = true + dataBytes, _ := json.Marshal(msg.Data) + var ir InputRequest + json.Unmarshal(dataBytes, &ir) + assert.Equal(t, "e2e-input-1", ir.ID) + assert.False(t, ir.Responded) + } + } + assert.True(t, foundInput) + + // Step 7: Submit response + inputBody := `{"request_id": "e2e-input-1", "response": "Go with approach A"}` + req, _ = http.NewRequest("POST", "http://"+addr+"/input", strings.NewReader(inputBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp.Body.Close() + + // Step 8: Verify response was stored + response, ok := server.GetPendingInput("e2e-input-1") + assert.True(t, ok) + assert.Equal(t, "Go with approach A", response) + + // Step 9: Client sees input as responded + req, _ = http.NewRequest("GET", "http://"+addr+"/stream?offset="+offset, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + require.NoError(t, json.Unmarshal(body, &messages)) + require.Len(t, messages, 1) + + dataBytes, _ := json.Marshal(messages[0].Data) + var finalInput InputRequest + json.Unmarshal(dataBytes, &finalInput) + assert.True(t, finalInput.Responded) + require.NotNil(t, finalInput.Response) + assert.Equal(t, "Go with approach A", *finalInput.Response) +} From bb544f1b5e1bbe58b227f5a6f9ac7aaebb2d3fc2 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Sat, 17 Jan 2026 23:07:20 +0000 Subject: [PATCH 13/19] docs: update CLI help and README with remote access documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add remote access section to start and resume command help text, documenting the --server, --port, and --password flags. Add comprehensive remote access section to README.md with usage examples for starting the web server and exposing via ngrok. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 30 ++++++++++++++++++++++++++++++ internal/cli/resume.go | 10 +++++++++- internal/cli/start.go | 10 +++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 980260b..bdbe3a5 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,36 @@ wisp start --repo org/repo --spec docs/my-rfc.md --sibling-repos org/shared-lib When the agent needs input, the TUI prompts for a response. Type and press Enter. +### Remote access + +Monitor and interact with sessions from any device using the built-in web server: + +```bash +wisp start --repo org/repo --spec docs/rfc.md --server +``` + +On first use, you'll be prompted to set a password. The server URL is printed +(default: `http://localhost:8374`). Expose via ngrok for mobile access: + +```bash +ngrok http 8374 +``` + +The web interface provides: +- Dashboard with active sessions +- Task list with completion status +- Live Claude output stream +- Input prompt for NEEDS_INPUT responses +- Browser notifications when input is needed + +Additional server options: + +```bash +wisp start ... --server --port 9000 # custom port +wisp start ... --server --password # change password +wisp resume wisp/my-feature --server # resume with server +``` + ### Complete and create PR ```bash diff --git a/internal/cli/resume.go b/internal/cli/resume.go index a4a89e5..c0aa6ff 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -30,9 +30,17 @@ restoring state from local storage, and continuing the iteration loop. The branch argument is required and must match an existing session. +Remote Access: + Use --server to start a web server alongside the TUI for monitoring and + interacting with the session from any device (phone, tablet, another computer). + On first use, you'll be prompted to set a password. Use --port to customize + the server port (default: 8374). Use --password to change the password. + Example: wisp resume wisp/my-feature - wisp resume feature/auth-implementation`, + wisp resume feature/auth-implementation + wisp resume wisp/my-feature --server + wisp resume wisp/my-feature --server --port 9000`, Args: cobra.ExactArgs(1), RunE: runResume, } diff --git a/internal/cli/start.go b/internal/cli/start.go index d2d8b35..0d322cd 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -53,10 +53,18 @@ and begins the iteration loop. The --repo and --spec flags are required. The spec path should be relative to the repository root and point to the RFC/specification document. +Remote Access: + Use --server to start a web server alongside the TUI for monitoring and + interacting with the session from any device (phone, tablet, another computer). + On first use, you'll be prompted to set a password. Use --port to customize + the server port (default: 8374). Use --password to change the password. + Example: wisp start --repo org/repo --spec docs/rfc.md wisp start --repo org/repo --spec docs/rfc.md --branch feature/my-feature - wisp start --repo org/repo --spec docs/rfc.md --sibling-repos org/other-repo`, + wisp start --repo org/repo --spec docs/rfc.md --sibling-repos org/other-repo + wisp start --repo org/repo --spec docs/rfc.md --server + wisp start --repo org/repo --spec docs/rfc.md --server --port 9000`, RunE: runStart, } From e22a3c0303d75cbd852102534c480cecf089f010 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 13:25:48 -0800 Subject: [PATCH 14/19] feat(cli): start web server when --server flag is used The --server flag was handling password setup but never actually starting the web server. Now both start and resume commands create, start, and wire up the server to the loop for state broadcasting. Co-Authored-By: Claude Opus 4.5 --- internal/cli/resume.go | 47 +++++++++++++++++++++++++++++++++++++++++- internal/cli/start.go | 46 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/internal/cli/resume.go b/internal/cli/resume.go index c0aa6ff..8595383 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -6,11 +6,13 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/spf13/cobra" "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/loop" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" @@ -166,8 +168,51 @@ func runResume(cmd *cobra.Command, args []string) error { // Get template directory templateDir := filepath.Join(cwd, ".wisp", "templates", templateName) + // Create web server if enabled + var srv *server.Server + if resumeServer { + var err error + srv, err = server.NewServerFromConfig(cfg.Server) + if err != nil { + return fmt.Errorf("failed to create web server: %w", err) + } + + // Start server in background + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.Start(ctx) + }() + + // Give the server a moment to start and check for errors + select { + case err := <-serverErrCh: + return fmt.Errorf("web server failed to start: %w", err) + case <-time.After(100 * time.Millisecond): + // Server started successfully + } + + fmt.Printf("Web server running at http://localhost:%d\n", cfg.Server.Port) + + // Ensure server is stopped when we exit + defer func() { + if err := srv.Stop(); err != nil { + fmt.Printf("Warning: failed to stop web server: %v\n", err) + } + }() + } + // Create and run loop - l := loop.NewLoop(client, syncMgr, store, cfg, session, t, repoPath, templateDir) + l := loop.NewLoopWithOptions(loop.LoopOptions{ + Client: client, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: t, + Server: srv, + RepoPath: repoPath, + TemplateDir: templateDir, + }) fmt.Printf("Resuming iteration loop...\n") diff --git a/internal/cli/start.go b/internal/cli/start.go index 0d322cd..69c27be 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -14,6 +14,7 @@ import ( "github.com/thruflo/wisp/internal/auth" "github.com/thruflo/wisp/internal/config" "github.com/thruflo/wisp/internal/loop" + "github.com/thruflo/wisp/internal/server" "github.com/thruflo/wisp/internal/sprite" "github.com/thruflo/wisp/internal/state" "github.com/thruflo/wisp/internal/tui" @@ -231,8 +232,51 @@ func runStart(cmd *cobra.Command, args []string) error { // Create TUI t := tui.NewTUI(os.Stdout) + // Create web server if enabled + var srv *server.Server + if startServer { + var err error + srv, err = server.NewServerFromConfig(cfg.Server) + if err != nil { + return fmt.Errorf("failed to create web server: %w", err) + } + + // Start server in background + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.Start(ctx) + }() + + // Give the server a moment to start and check for errors + select { + case err := <-serverErrCh: + return fmt.Errorf("web server failed to start: %w", err) + case <-time.After(100 * time.Millisecond): + // Server started successfully + } + + fmt.Printf("Web server running at http://localhost:%d\n", cfg.Server.Port) + + // Ensure server is stopped when we exit + defer func() { + if err := srv.Stop(); err != nil { + fmt.Printf("Warning: failed to stop web server: %v\n", err) + } + }() + } + // Create and run loop - l := loop.NewLoop(client, syncMgr, store, cfg, session, t, repoPath, templateDir) + l := loop.NewLoopWithOptions(loop.LoopOptions{ + Client: client, + SyncManager: syncMgr, + Store: store, + Config: cfg, + Session: session, + TUI: t, + Server: srv, + RepoPath: repoPath, + TemplateDir: templateDir, + }) fmt.Printf("Starting iteration loop...\n") From 9d300e8f88391581a1f25ff7091385d782e1412e Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 13:30:45 -0800 Subject: [PATCH 15/19] fix(server): parse auth request body as JSON instead of form data The web client sends JSON for authentication but the server was expecting form-urlencoded data, causing a 400 error on login. Co-Authored-By: Claude Opus 4.5 --- internal/server/server.go | 20 ++++++++++---- internal/server/server_integration_test.go | 25 ++++++++--------- internal/server/server_test.go | 32 ++++++++-------------- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 329e1d8..68204f0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -789,18 +789,28 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { return } - // Parse form data - if err := r.ParseForm(); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) + // Parse JSON body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + var req struct { + Password string `json:"password"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) return } - password := r.FormValue("password") - if password == "" { + if req.Password == "" { http.Error(w, "password required", http.StatusBadRequest) return } + password := req.Password + // Verify password valid, err := s.VerifyPassword(password) if err != nil { diff --git a/internal/server/server_integration_test.go b/internal/server/server_integration_test.go index d38775e..626e920 100644 --- a/internal/server/server_integration_test.go +++ b/internal/server/server_integration_test.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "sync" "sync/atomic" @@ -53,8 +52,8 @@ func TestIntegrationAuthFlow(t *testing.T) { t.Run("correct_password_returns_valid_token", func(t *testing.T) { // Authenticate with correct password - form := url.Values{"password": {testPassword}} - resp, err := http.PostForm("http://"+addr+"/auth", form) + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() @@ -78,8 +77,7 @@ func TestIntegrationAuthFlow(t *testing.T) { }) t.Run("incorrect_password_returns_401", func(t *testing.T) { - form := url.Values{"password": {"wrong-password"}} - resp, err := http.PostForm("http://"+addr+"/auth", form) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(`{"password":"wrong-password"}`)) require.NoError(t, err) defer resp.Body.Close() @@ -87,8 +85,7 @@ func TestIntegrationAuthFlow(t *testing.T) { }) t.Run("empty_password_returns_400", func(t *testing.T) { - form := url.Values{"password": {""}} - resp, err := http.PostForm("http://"+addr+"/auth", form) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(`{"password":""}`)) require.NoError(t, err) defer resp.Body.Close() @@ -118,8 +115,8 @@ func TestIntegrationAuthFlow(t *testing.T) { // Get multiple tokens var tokens []string for i := 0; i < 3; i++ { - form := url.Values{"password": {testPassword}} - resp, err := http.PostForm("http://"+addr+"/auth", form) + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) require.NoError(t, err) var authResp struct { @@ -852,8 +849,8 @@ func TestIntegrationServerLifecycle(t *testing.T) { require.NotEmpty(t, addr) // Verify working - form := url.Values{"password": {testPassword}} - resp, err := http.PostForm("http://"+addr+"/auth", form) + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) require.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) resp.Body.Close() @@ -873,7 +870,7 @@ func TestIntegrationServerLifecycle(t *testing.T) { // Should be stopped - connection should be refused client := &http.Client{Timeout: 1 * time.Second} - _, err = client.PostForm("http://"+addr+"/auth", form) + _, err = client.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) assert.Error(t, err, "expected connection refused after server stop") }) @@ -939,8 +936,8 @@ func TestIntegrationEndToEndFlow(t *testing.T) { addr := server.ListenAddr() // Step 1: Authenticate - form := url.Values{"password": {testPassword}} - resp, err := http.PostForm("http://"+addr+"/auth", form) + authBody := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(authBody)) require.NoError(t, err) var authResp struct { Token string `json:"token"` diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5f1bda0..ab9c930 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "strings" "sync" "testing" @@ -252,8 +251,8 @@ func TestHandleAuth(t *testing.T) { }) t.Run("missing password", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/auth", nil) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -264,10 +263,8 @@ func TestHandleAuth(t *testing.T) { }) t.Run("wrong password", func(t *testing.T) { - form := url.Values{} - form.Add("password", "wrong-password") - req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(`{"password":"wrong-password"}`)) + req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -278,10 +275,9 @@ func TestHandleAuth(t *testing.T) { }) t.Run("correct password", func(t *testing.T) { - form := url.Values{} - form.Add("password", testPassword) - req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + req := httptest.NewRequest(http.MethodPost, "/auth", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -411,9 +407,8 @@ func TestAuthEndToEnd(t *testing.T) { addr := server.ListenAddr() // Authenticate with correct password - form := url.Values{} - form.Add("password", testPassword) - resp, err := http.PostForm("http://"+addr+"/auth", form) + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("failed to authenticate: %v", err) } @@ -436,9 +431,7 @@ func TestAuthEndToEnd(t *testing.T) { } // Authenticate with wrong password - form = url.Values{} - form.Add("password", "wrong-password") - resp2, err := http.PostForm("http://"+addr+"/auth", form) + resp2, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(`{"password":"wrong-password"}`)) if err != nil { t.Fatalf("failed to make request: %v", err) } @@ -452,9 +445,8 @@ func TestAuthEndToEnd(t *testing.T) { // Helper to get an authenticated token for tests func getAuthToken(t *testing.T, addr string) string { t.Helper() - form := url.Values{} - form.Add("password", testPassword) - resp, err := http.PostForm("http://"+addr+"/auth", form) + body := fmt.Sprintf(`{"password":"%s"}`, testPassword) + resp, err := http.Post("http://"+addr+"/auth", "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("failed to authenticate: %v", err) } From ed251aaa9a87e00ab52f939fd8f721c027a489a9 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 14:21:37 -0800 Subject: [PATCH 16/19] fix(web): fix auth flow and stream connection issues - Use absolute URL for stream endpoint (fixes "Invalid URL" error) - Fix import from @tanstack/db to @tanstack/react-db (fixes build) - Don't reset token on connection error (show error screen instead of silently redirecting to login) Co-Authored-By: Claude Opus 4.5 --- web/src/App.tsx | 1 - web/src/db/actions.ts | 2 +- web/src/db/store.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2244c6f..a65e037 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -50,7 +50,6 @@ export function App() { if (cancelled) return setError(err.message) setConnectionState('error') - setToken(null) }) return () => { cancelled = true diff --git a/web/src/db/actions.ts b/web/src/db/actions.ts index 6cd5ff1..8f97b45 100644 --- a/web/src/db/actions.ts +++ b/web/src/db/actions.ts @@ -1,4 +1,4 @@ -import { createOptimisticAction } from '@tanstack/db' +import { createOptimisticAction } from '@tanstack/react-db' import type { WispDB } from './store' export function createActions(db: WispDB, token: string) { diff --git a/web/src/db/store.ts b/web/src/db/store.ts index c03aa4a..964bee4 100644 --- a/web/src/db/store.ts +++ b/web/src/db/store.ts @@ -12,7 +12,7 @@ export interface CreateDbOptions { export function createDb({ token, onDisconnect }: CreateDbOptions) { return createStreamDB({ streamOptions: { - url: '/stream', + url: `${window.location.origin}/stream`, headers: { Authorization: `Bearer ${token}` }, onError: (error) => { // For connection errors (server gone), trigger disconnect From f075919500bc54db8426162e28c5cb4eb8cdcdcf Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 14:22:35 -0800 Subject: [PATCH 17/19] feat(dev): add standalone debug server for testing web auth Starts a minimal server on port 8375 for debugging the web client auth flow without running a full wisp session. Usage: go run ./cmd/debug-server [password] Co-Authored-By: Claude Opus 4.5 --- cmd/debug-server/main.go | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 cmd/debug-server/main.go diff --git a/cmd/debug-server/main.go b/cmd/debug-server/main.go new file mode 100644 index 0000000..be94858 --- /dev/null +++ b/cmd/debug-server/main.go @@ -0,0 +1,69 @@ +// Simple standalone server for debugging auth flow. +// Run with: go run ./cmd/debug-server +// Make sure to rebuild web assets first: cd web && npm run build +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/thruflo/wisp/internal/auth" + "github.com/thruflo/wisp/internal/server" + "github.com/thruflo/wisp/web" +) + +func main() { + password := "test123" + if len(os.Args) > 1 { + password = os.Args[1] + } + + hash, err := auth.HashPassword(password) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to hash password: %v\n", err) + os.Exit(1) + } + + // Check if web/dist exists (fresh build) + if stat, err := os.Stat("./web/dist"); err != nil || !stat.IsDir() { + fmt.Fprintln(os.Stderr, "Warning: ./web/dist not found. Run 'cd web && npm run build' first.") + fmt.Fprintln(os.Stderr, "Using embedded assets (may be stale).") + } else { + fmt.Println("Using live assets from ./web/dist") + } + + srv, err := server.NewServer(&server.Config{ + Port: 8375, + PasswordHash: hash, + Assets: web.GetAssets("./web/dist"), + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create server: %v\n", err) + os.Exit(1) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle Ctrl+C + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Println("\nShutting down...") + cancel() + }() + + go srv.Start(ctx) + + fmt.Printf("Server running on http://localhost:%d\n", srv.Port()) + fmt.Printf("Password: %s\n", password) + fmt.Println("\nTest with:") + fmt.Printf(" curl -X POST http://localhost:%d/auth -H 'Content-Type: application/json' -d '{\"password\":\"%s\"}'\n", srv.Port(), password) + + <-ctx.Done() + srv.Stop() +} From 95abb53e2f8450bf460c0299f3c47fcda96d5a93 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 14:31:06 -0800 Subject: [PATCH 18/19] fix tests --- internal/cli/start.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cli/start.go b/internal/cli/start.go index 69c27be..b5bf874 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -28,6 +28,7 @@ var ( startTemplate string startCheckpoint string startHeadless bool + startContinue bool startServer bool startServerPort int startSetPassword bool From 2c93e4643a74eb920180fcbc767428db056d1cd5 Mon Sep 17 00:00:00 2001 From: James Arthur Date: Mon, 19 Jan 2026 14:39:21 -0800 Subject: [PATCH 19/19] use correct mutex --- internal/server/server_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index ab9c930..6787e33 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -873,12 +873,12 @@ func TestPendingInputConcurrency(t *testing.T) { reqID := fmt.Sprintf("req-%d", id) // Store - server.mu.Lock() + server.inputMu.Lock() if server.pendingInputs == nil { server.pendingInputs = make(map[string]string) } server.pendingInputs[reqID] = fmt.Sprintf("response-%d", id) - server.mu.Unlock() + server.inputMu.Unlock() // Retrieve resp, ok := server.GetPendingInput(reqID)