Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/knotcompat: knot capability detection

Lewis: May this revision serve well! <lewis@tangled.org>

authored by

Lewis and committed by
Tangled
(Jun 8, 2026, 4:18 PM +0300) db265537 d588ea08

+430 -295
-87
appview/compat113/compat.go
··· 1 - package compat113 2 - 3 - import ( 4 - "encoding/json" 5 - "io" 6 - 7 - lexutil "github.com/bluesky-social/indigo/lex/util" 8 - "tangled.org/core/api/tangled" 9 - ) 10 - 11 - func Collaborator(r *tangled.RepoCollaborator) *lexutil.LexiconTypeDecoder { 12 - return &lexutil.LexiconTypeDecoder{Val: &collaboratorWrapper{inner: r}} 13 - } 14 - 15 - func Pull(r *tangled.RepoPull) *lexutil.LexiconTypeDecoder { 16 - return &lexutil.LexiconTypeDecoder{Val: &pullWrapper{inner: r}} 17 - } 18 - 19 - type collaboratorWrapper struct { 20 - LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 - inner *tangled.RepoCollaborator 22 - } 23 - 24 - func (c *collaboratorWrapper) MarshalJSON() ([]byte, error) { 25 - c.inner.LexiconTypeID = "sh.tangled.repo.collaborator" 26 - return marshalWithRepoDidShadow(c.inner, false) 27 - } 28 - 29 - func (c *collaboratorWrapper) MarshalCBOR(w io.Writer) error { 30 - return c.inner.MarshalCBOR(w) 31 - } 32 - 33 - type pullWrapper struct { 34 - LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.pull"` 35 - inner *tangled.RepoPull 36 - } 37 - 38 - func (c *pullWrapper) MarshalJSON() ([]byte, error) { 39 - c.inner.LexiconTypeID = "sh.tangled.repo.pull" 40 - return marshalWithRepoDidShadow(c.inner, true) 41 - } 42 - 43 - func (c *pullWrapper) MarshalCBOR(w io.Writer) error { 44 - return c.inner.MarshalCBOR(w) 45 - } 46 - 47 - func marshalWithRepoDidShadow(inner any, nestedTarget bool) ([]byte, error) { 48 - raw, err := json.Marshal(inner) 49 - if err != nil { 50 - return nil, err 51 - } 52 - var top map[string]json.RawMessage 53 - if err := json.Unmarshal(raw, &top); err != nil { 54 - return raw, nil 55 - } 56 - if nestedTarget { 57 - injectIntoNested(top, "target") 58 - injectIntoNested(top, "source") 59 - } else { 60 - addRepoDidShadow(top) 61 - } 62 - return json.Marshal(top) 63 - } 64 - 65 - func injectIntoNested(parent map[string]json.RawMessage, key string) { 66 - raw, ok := parent[key] 67 - if !ok { 68 - return 69 - } 70 - var nested map[string]json.RawMessage 71 - if err := json.Unmarshal(raw, &nested); err != nil { 72 - return 73 - } 74 - addRepoDidShadow(nested) 75 - if reb, err := json.Marshal(nested); err == nil { 76 - parent[key] = reb 77 - } 78 - } 79 - 80 - func addRepoDidShadow(m map[string]json.RawMessage) { 81 - if _, has := m["repoDid"]; has { 82 - return 83 - } 84 - if v, ok := m["repo"]; ok { 85 - m["repoDid"] = v 86 - } 87 - }
-113
appview/compat113/compat_test.go
··· 1 - package compat113 2 - 3 - import ( 4 - "encoding/json" 5 - "testing" 6 - 7 - "tangled.org/core/api/tangled" 8 - ) 9 - 10 - func ptr[T any](v T) *T { return &v } 11 - 12 - func TestCollaboratorShadowsRepoDid(t *testing.T) { 13 - rec := &tangled.RepoCollaborator{ 14 - CreatedAt: "2026-05-08T00:00:00Z", 15 - Repo: "did:plc:abalone", 16 - Subject: "did:plc:limpet", 17 - } 18 - 19 - out, err := json.Marshal(Collaborator(rec)) 20 - if err != nil { 21 - t.Fatalf("marshal: %v", err) 22 - } 23 - 24 - var got map[string]any 25 - if err := json.Unmarshal(out, &got); err != nil { 26 - t.Fatalf("unmarshal: %v", err) 27 - } 28 - 29 - if got["$type"] != "sh.tangled.repo.collaborator" { 30 - t.Errorf("$type = %v, want sh.tangled.repo.collaborator", got["$type"]) 31 - } 32 - if got["repo"] != "did:plc:abalone" { 33 - t.Errorf("repo = %v, want did:plc:abalone", got["repo"]) 34 - } 35 - if got["repoDid"] != "did:plc:abalone" { 36 - t.Errorf("repoDid shadow missing or wrong: got %v", got["repoDid"]) 37 - } 38 - } 39 - 40 - func TestPullShadowsTargetRepoDid(t *testing.T) { 41 - rec := &tangled.RepoPull{ 42 - CreatedAt: "2026-05-08T00:00:00Z", 43 - Title: "rename whelk handler", 44 - Target: &tangled.RepoPull_Target{ 45 - Branch: "main", 46 - Repo: "did:plc:scallop", 47 - }, 48 - Source: &tangled.RepoPull_Source{ 49 - Branch: "feature-1", 50 - }, 51 - } 52 - 53 - out, err := json.Marshal(Pull(rec)) 54 - if err != nil { 55 - t.Fatalf("marshal: %v", err) 56 - } 57 - 58 - var got map[string]any 59 - if err := json.Unmarshal(out, &got); err != nil { 60 - t.Fatalf("unmarshal: %v", err) 61 - } 62 - 63 - target, ok := got["target"].(map[string]any) 64 - if !ok { 65 - t.Fatalf("target missing or wrong type: %v", got["target"]) 66 - } 67 - if target["repo"] != "did:plc:scallop" { 68 - t.Errorf("target.repo = %v", target["repo"]) 69 - } 70 - if target["repoDid"] != "did:plc:scallop" { 71 - t.Errorf("target.repoDid shadow missing: %v", target["repoDid"]) 72 - } 73 - 74 - if _, has := got["repoDid"]; has { 75 - t.Errorf("top-level repoDid should not be set on pull: %v", got["repoDid"]) 76 - } 77 - } 78 - 79 - func TestPullShadowsForkSourceRepoDid(t *testing.T) { 80 - rec := &tangled.RepoPull{ 81 - CreatedAt: "2026-05-08T00:00:00Z", 82 - Title: "fork-based PR", 83 - Target: &tangled.RepoPull_Target{ 84 - Branch: "main", 85 - Repo: "did:plc:scallop", 86 - }, 87 - Source: &tangled.RepoPull_Source{ 88 - Branch: "feature-2", 89 - Repo: ptr("did:plc:periwinkle"), 90 - }, 91 - } 92 - 93 - out, err := json.Marshal(Pull(rec)) 94 - if err != nil { 95 - t.Fatalf("marshal: %v", err) 96 - } 97 - 98 - var got map[string]any 99 - if err := json.Unmarshal(out, &got); err != nil { 100 - t.Fatalf("unmarshal: %v", err) 101 - } 102 - 103 - source, ok := got["source"].(map[string]any) 104 - if !ok { 105 - t.Fatalf("source missing: %v", got["source"]) 106 - } 107 - if source["repo"] != "did:plc:periwinkle" { 108 - t.Errorf("source.repo = %v", source["repo"]) 109 - } 110 - if source["repoDid"] != "did:plc:periwinkle" { 111 - t.Errorf("source.repoDid shadow missing: %v", source["repoDid"]) 112 - } 113 - }
-60
appview/compat113/version.go
··· 1 - package compat113 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "strconv" 8 - "strings" 9 - "time" 10 - 11 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 - "tangled.org/core/api/tangled" 13 - ) 14 - 15 - const versionProbeTimeout = 5 * time.Second 16 - 17 - func KnotSupports114(ctx context.Context, host string, dev bool) bool { 18 - scheme := "https" 19 - if dev { 20 - scheme = "http" 21 - } 22 - client := &indigoxrpc.Client{ 23 - Host: fmt.Sprintf("%s://%s", scheme, host), 24 - Client: &http.Client{Timeout: versionProbeTimeout}, 25 - } 26 - 27 - ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout) 28 - defer cancel() 29 - 30 - resp, err := tangled.KnotVersion(ctx, client) 31 - if err != nil || resp == nil { 32 - return true 33 - } 34 - return atLeast114(resp.Version) 35 - } 36 - 37 - func atLeast114(v string) bool { 38 - v = strings.TrimSpace(v) 39 - v = strings.TrimPrefix(v, "v") 40 - if strings.HasPrefix(v, "(devel)") { 41 - return true 42 - } 43 - if v == "" { 44 - return false 45 - } 46 - parts := strings.SplitN(v, ".", 3) 47 - if len(parts) < 2 { 48 - return false 49 - } 50 - major, err := strconv.Atoi(parts[0]) 51 - if err != nil { 52 - return false 53 - } 54 - minorRaw := strings.SplitN(parts[1], "-", 2)[0] 55 - minor, err := strconv.Atoi(minorRaw) 56 - if err != nil { 57 - return false 58 - } 59 - return major > 1 || (major == 1 && minor >= 14) 60 - }
-35
appview/compat113/version_test.go
··· 1 - package compat113 2 - 3 - import "testing" 4 - 5 - func TestAtLeast114(t *testing.T) { 6 - cases := []struct { 7 - in string 8 - want bool 9 - }{ 10 - {"v1.14.0", true}, 11 - {"v1.14.0-alpha", true}, 12 - {"v1.14.5", true}, 13 - {"v1.13.0", false}, 14 - {"v1.13.0-alpha", false}, 15 - {"v1.0.0", false}, 16 - {"v2.0.0", true}, 17 - {"1.14.0", true}, 18 - {"1.13.99", false}, 19 - {"(devel)", true}, 20 - {"", false}, 21 - {"garbagio-furioso", false}, 22 - {"v1", false}, 23 - {"vX.Y.Z", false}, 24 - {"unknown", false}, 25 - {"unknown-abc1234", false}, 26 - {"unknown-abc1234-modified", false}, 27 - } 28 - for _, c := range cases { 29 - t.Run(c.in, func(t *testing.T) { 30 - if got := atLeast114(c.in); got != c.want { 31 - t.Errorf("atLeast114(%q) = %v, want %v", c.in, got, c.want) 32 - } 33 - }) 34 - } 35 - }
+87
appview/knotcompat/compat.go
··· 1 + package knotcompat 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + 7 + lexutil "github.com/bluesky-social/indigo/lex/util" 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + func Collaborator(r *tangled.RepoCollaborator) *lexutil.LexiconTypeDecoder { 12 + return &lexutil.LexiconTypeDecoder{Val: &collaboratorWrapper{inner: r}} 13 + } 14 + 15 + func Pull(r *tangled.RepoPull) *lexutil.LexiconTypeDecoder { 16 + return &lexutil.LexiconTypeDecoder{Val: &pullWrapper{inner: r}} 17 + } 18 + 19 + type collaboratorWrapper struct { 20 + LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 + inner *tangled.RepoCollaborator 22 + } 23 + 24 + func (c *collaboratorWrapper) MarshalJSON() ([]byte, error) { 25 + c.inner.LexiconTypeID = "sh.tangled.repo.collaborator" 26 + return marshalWithRepoDidShadow(c.inner, false) 27 + } 28 + 29 + func (c *collaboratorWrapper) MarshalCBOR(w io.Writer) error { 30 + return c.inner.MarshalCBOR(w) 31 + } 32 + 33 + type pullWrapper struct { 34 + LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.pull"` 35 + inner *tangled.RepoPull 36 + } 37 + 38 + func (c *pullWrapper) MarshalJSON() ([]byte, error) { 39 + c.inner.LexiconTypeID = "sh.tangled.repo.pull" 40 + return marshalWithRepoDidShadow(c.inner, true) 41 + } 42 + 43 + func (c *pullWrapper) MarshalCBOR(w io.Writer) error { 44 + return c.inner.MarshalCBOR(w) 45 + } 46 + 47 + func marshalWithRepoDidShadow(inner any, nestedTarget bool) ([]byte, error) { 48 + raw, err := json.Marshal(inner) 49 + if err != nil { 50 + return nil, err 51 + } 52 + var top map[string]json.RawMessage 53 + if err := json.Unmarshal(raw, &top); err != nil { 54 + return raw, nil 55 + } 56 + if nestedTarget { 57 + injectIntoNested(top, "target") 58 + injectIntoNested(top, "source") 59 + } else { 60 + addRepoDidShadow(top) 61 + } 62 + return json.Marshal(top) 63 + } 64 + 65 + func injectIntoNested(parent map[string]json.RawMessage, key string) { 66 + raw, ok := parent[key] 67 + if !ok { 68 + return 69 + } 70 + var nested map[string]json.RawMessage 71 + if err := json.Unmarshal(raw, &nested); err != nil { 72 + return 73 + } 74 + addRepoDidShadow(nested) 75 + if reb, err := json.Marshal(nested); err == nil { 76 + parent[key] = reb 77 + } 78 + } 79 + 80 + func addRepoDidShadow(m map[string]json.RawMessage) { 81 + if _, has := m["repoDid"]; has { 82 + return 83 + } 84 + if v, ok := m["repo"]; ok { 85 + m["repoDid"] = v 86 + } 87 + }
+113
appview/knotcompat/compat_test.go
··· 1 + package knotcompat 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + 7 + "tangled.org/core/api/tangled" 8 + ) 9 + 10 + func ptr[T any](v T) *T { return &v } 11 + 12 + func TestCollaboratorShadowsRepoDid(t *testing.T) { 13 + rec := &tangled.RepoCollaborator{ 14 + CreatedAt: "2026-05-08T00:00:00Z", 15 + Repo: "did:plc:abalone", 16 + Subject: "did:plc:limpet", 17 + } 18 + 19 + out, err := json.Marshal(Collaborator(rec)) 20 + if err != nil { 21 + t.Fatalf("marshal: %v", err) 22 + } 23 + 24 + var got map[string]any 25 + if err := json.Unmarshal(out, &got); err != nil { 26 + t.Fatalf("unmarshal: %v", err) 27 + } 28 + 29 + if got["$type"] != "sh.tangled.repo.collaborator" { 30 + t.Errorf("$type = %v, want sh.tangled.repo.collaborator", got["$type"]) 31 + } 32 + if got["repo"] != "did:plc:abalone" { 33 + t.Errorf("repo = %v, want did:plc:abalone", got["repo"]) 34 + } 35 + if got["repoDid"] != "did:plc:abalone" { 36 + t.Errorf("repoDid shadow missing or wrong: got %v", got["repoDid"]) 37 + } 38 + } 39 + 40 + func TestPullShadowsTargetRepoDid(t *testing.T) { 41 + rec := &tangled.RepoPull{ 42 + CreatedAt: "2026-05-08T00:00:00Z", 43 + Title: "rename whelk handler", 44 + Target: &tangled.RepoPull_Target{ 45 + Branch: "main", 46 + Repo: "did:plc:scallop", 47 + }, 48 + Source: &tangled.RepoPull_Source{ 49 + Branch: "feature-1", 50 + }, 51 + } 52 + 53 + out, err := json.Marshal(Pull(rec)) 54 + if err != nil { 55 + t.Fatalf("marshal: %v", err) 56 + } 57 + 58 + var got map[string]any 59 + if err := json.Unmarshal(out, &got); err != nil { 60 + t.Fatalf("unmarshal: %v", err) 61 + } 62 + 63 + target, ok := got["target"].(map[string]any) 64 + if !ok { 65 + t.Fatalf("target missing or wrong type: %v", got["target"]) 66 + } 67 + if target["repo"] != "did:plc:scallop" { 68 + t.Errorf("target.repo = %v", target["repo"]) 69 + } 70 + if target["repoDid"] != "did:plc:scallop" { 71 + t.Errorf("target.repoDid shadow missing: %v", target["repoDid"]) 72 + } 73 + 74 + if _, has := got["repoDid"]; has { 75 + t.Errorf("top-level repoDid should not be set on pull: %v", got["repoDid"]) 76 + } 77 + } 78 + 79 + func TestPullShadowsForkSourceRepoDid(t *testing.T) { 80 + rec := &tangled.RepoPull{ 81 + CreatedAt: "2026-05-08T00:00:00Z", 82 + Title: "fork-based PR", 83 + Target: &tangled.RepoPull_Target{ 84 + Branch: "main", 85 + Repo: "did:plc:scallop", 86 + }, 87 + Source: &tangled.RepoPull_Source{ 88 + Branch: "feature-2", 89 + Repo: ptr("did:plc:periwinkle"), 90 + }, 91 + } 92 + 93 + out, err := json.Marshal(Pull(rec)) 94 + if err != nil { 95 + t.Fatalf("marshal: %v", err) 96 + } 97 + 98 + var got map[string]any 99 + if err := json.Unmarshal(out, &got); err != nil { 100 + t.Fatalf("unmarshal: %v", err) 101 + } 102 + 103 + source, ok := got["source"].(map[string]any) 104 + if !ok { 105 + t.Fatalf("source missing: %v", got["source"]) 106 + } 107 + if source["repo"] != "did:plc:periwinkle" { 108 + t.Errorf("source.repo = %v", source["repo"]) 109 + } 110 + if source["repoDid"] != "did:plc:periwinkle" { 111 + t.Errorf("source.repoDid shadow missing: %v", source["repoDid"]) 112 + } 113 + }
+230
appview/knotcompat/version.go
··· 1 + package knotcompat 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "slices" 8 + "strconv" 9 + "strings" 10 + "sync" 11 + "sync/atomic" 12 + "time" 13 + 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/consts" 17 + ) 18 + 19 + const ( 20 + versionProbeTimeout = 5 * time.Second 21 + versionProbeFresh = 5 * time.Minute 22 + versionProbeTrust = time.Hour 23 + versionProbeCacheMax = 4096 24 + ) 25 + 26 + type versionProbeEntry struct { 27 + version string 28 + probedAt time.Time 29 + } 30 + 31 + type versionProbeCache struct { 32 + mu sync.Mutex 33 + entries map[string]versionProbeEntry 34 + } 35 + 36 + func (c *versionProbeCache) get(host string) (versionProbeEntry, bool) { 37 + c.mu.Lock() 38 + defer c.mu.Unlock() 39 + e, ok := c.entries[host] 40 + return e, ok 41 + } 42 + 43 + func (c *versionProbeCache) put(host, version string, at time.Time) { 44 + c.mu.Lock() 45 + defer c.mu.Unlock() 46 + if _, exists := c.entries[host]; !exists && len(c.entries) >= versionProbeCacheMax { 47 + c.evictLocked(at) 48 + } 49 + c.entries[host] = versionProbeEntry{version: version, probedAt: at} 50 + } 51 + 52 + func (c *versionProbeCache) evictLocked(now time.Time) { 53 + oldestHost := "" 54 + var oldestAt time.Time 55 + for h, e := range c.entries { 56 + if now.Sub(e.probedAt) >= versionProbeTrust { 57 + delete(c.entries, h) 58 + continue 59 + } 60 + if oldestHost == "" || e.probedAt.Before(oldestAt) { 61 + oldestHost, oldestAt = h, e.probedAt 62 + } 63 + } 64 + if len(c.entries) >= versionProbeCacheMax && oldestHost != "" { 65 + delete(c.entries, oldestHost) 66 + } 67 + } 68 + 69 + func (c *versionProbeCache) supports(now time.Time, host string, minMajor, minMinor int, failOpen bool, probe func() (string, bool)) bool { 70 + if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeFresh { 71 + return atLeast(e.version, minMajor, minMinor) 72 + } 73 + if version, ok := probe(); ok { 74 + c.put(host, version, now) 75 + return atLeast(version, minMajor, minMinor) 76 + } 77 + if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeTrust { 78 + return atLeast(e.version, minMajor, minMinor) 79 + } 80 + return failOpen 81 + } 82 + 83 + var probeCache = &versionProbeCache{entries: map[string]versionProbeEntry{}} 84 + 85 + type NativeLatch interface { 86 + IsNative(host string) bool 87 + MarkNative(host string) 88 + } 89 + 90 + type latchBox struct{ l NativeLatch } 91 + 92 + type nativeGate struct { 93 + memo sync.Map 94 + negMemo sync.Map 95 + latch atomic.Pointer[latchBox] 96 + useOnce sync.Once 97 + now func() time.Time 98 + } 99 + 100 + func (g *nativeGate) clock() time.Time { 101 + if g.now != nil { 102 + return g.now() 103 + } 104 + return time.Now() 105 + } 106 + 107 + func (g *nativeGate) use(l NativeLatch) { 108 + g.useOnce.Do(func() { 109 + g.latch.Store(&latchBox{l: l}) 110 + }) 111 + } 112 + 113 + func (g *nativeGate) currentLatch() NativeLatch { 114 + if b := g.latch.Load(); b != nil { 115 + return b.l 116 + } 117 + return nil 118 + } 119 + 120 + func (g *nativeGate) isNative(host string, probe func() bool) bool { 121 + if _, ok := g.memo.Load(host); ok { 122 + return true 123 + } 124 + if until, ok := g.negMemo.Load(host); ok { 125 + if g.clock().Before(until.(time.Time)) { 126 + return false 127 + } 128 + g.negMemo.Delete(host) 129 + } 130 + if l := g.currentLatch(); l != nil && l.IsNative(host) { 131 + g.memo.Store(host, struct{}{}) 132 + return true 133 + } 134 + if !probe() { 135 + g.negMemo.Store(host, g.clock().Add(versionProbeFresh)) 136 + return false 137 + } 138 + g.memo.Store(host, struct{}{}) 139 + if l := g.currentLatch(); l != nil { 140 + l.MarkNative(host) 141 + } 142 + return true 143 + } 144 + 145 + var nativeProbeGate = &nativeGate{} 146 + 147 + func UseNativeLatch(l NativeLatch) { 148 + nativeProbeGate.use(l) 149 + } 150 + 151 + func KnotSupports114(ctx context.Context, host string, dev bool) bool { 152 + return knotSupportsVersion(ctx, host, dev, 1, 14, true) 153 + } 154 + 155 + func KnotHasCapability(ctx context.Context, host string, dev bool, capability consts.Capability) bool { 156 + return nativeProbeGate.isNative(host, func() bool { 157 + return knotDeclares(ctx, host, dev, capability) 158 + }) 159 + } 160 + 161 + func knotDeclares(ctx context.Context, host string, dev bool, capability consts.Capability) bool { 162 + scheme := "https" 163 + if dev { 164 + scheme = "http" 165 + } 166 + client := &indigoxrpc.Client{ 167 + Host: fmt.Sprintf("%s://%s", scheme, host), 168 + Client: &http.Client{Timeout: versionProbeTimeout}, 169 + } 170 + 171 + ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout) 172 + defer cancel() 173 + 174 + resp, err := tangled.KnotVersion(ctx, client) 175 + if err != nil || resp == nil { 176 + return false 177 + } 178 + return slices.Contains(resp.Capabilities, string(capability)) 179 + } 180 + 181 + func knotSupportsVersion(ctx context.Context, host string, dev bool, minMajor, minMinor int, failOpen bool) bool { 182 + return probeCache.supports(time.Now(), host, minMajor, minMinor, failOpen, func() (string, bool) { 183 + return probeVersion(ctx, host, dev) 184 + }) 185 + } 186 + 187 + func probeVersion(ctx context.Context, host string, dev bool) (string, bool) { 188 + scheme := "https" 189 + if dev { 190 + scheme = "http" 191 + } 192 + client := &indigoxrpc.Client{ 193 + Host: fmt.Sprintf("%s://%s", scheme, host), 194 + Client: &http.Client{Timeout: versionProbeTimeout}, 195 + } 196 + 197 + ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout) 198 + defer cancel() 199 + 200 + resp, err := tangled.KnotVersion(ctx, client) 201 + if err != nil || resp == nil { 202 + return "", false 203 + } 204 + return resp.Version, true 205 + } 206 + 207 + func atLeast(v string, minMajor, minMinor int) bool { 208 + v = strings.TrimSpace(v) 209 + v = strings.TrimPrefix(v, "v") 210 + if strings.HasPrefix(v, "(devel)") { 211 + return true 212 + } 213 + if v == "" { 214 + return false 215 + } 216 + parts := strings.SplitN(v, ".", 3) 217 + if len(parts) < 2 { 218 + return false 219 + } 220 + major, err := strconv.Atoi(parts[0]) 221 + if err != nil { 222 + return false 223 + } 224 + minorRaw := strings.SplitN(parts[1], "-", 2)[0] 225 + minor, err := strconv.Atoi(minorRaw) 226 + if err != nil { 227 + return false 228 + } 229 + return major > minMajor || (major == minMajor && minor >= minMinor) 230 + }