From db265537f1c013cebc36530a82bdb726cb1bec53 Mon Sep 17 00:00:00 2001 From: Lewis Date: Fri, 05 Jun 2026 06:28:47 +0000 Subject: [PATCH] appview/knotcompat: knot capability detection Lewis: May this revision serve well! --- appview/compat113/compat.go | 87 --------------------------------------------------------------------------------------- appview/compat113/compat_test.go | 113 ----------------------------------------------------------------------------------------------------------------- appview/compat113/version.go | 60 ------------------------------------------------------------ appview/compat113/version_test.go | 35 ----------------------------------- appview/knotcompat/compat.go | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/knotcompat/compat_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/knotcompat/version.go | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 file(s) changed, 430 insertion(s)(+), 295 deletion(s)(-) diff --git a/appview/compat113/compat.go b/appview/compat113/compat.go deleted file mode 100644 --- a/appview/compat113/compat.go +++ /dev/null @@ -1,87 +0,0 @@ -package compat113 - -import ( - "encoding/json" - "io" - - lexutil "github.com/bluesky-social/indigo/lex/util" - "tangled.org/core/api/tangled" -) - -func Collaborator(r *tangled.RepoCollaborator) *lexutil.LexiconTypeDecoder { - return &lexutil.LexiconTypeDecoder{Val: &collaboratorWrapper{inner: r}} -} - -func Pull(r *tangled.RepoPull) *lexutil.LexiconTypeDecoder { - return &lexutil.LexiconTypeDecoder{Val: &pullWrapper{inner: r}} -} - -type collaboratorWrapper struct { - LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.collaborator"` - inner *tangled.RepoCollaborator -} - -func (c *collaboratorWrapper) MarshalJSON() ([]byte, error) { - c.inner.LexiconTypeID = "sh.tangled.repo.collaborator" - return marshalWithRepoDidShadow(c.inner, false) -} - -func (c *collaboratorWrapper) MarshalCBOR(w io.Writer) error { - return c.inner.MarshalCBOR(w) -} - -type pullWrapper struct { - LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.pull"` - inner *tangled.RepoPull -} - -func (c *pullWrapper) MarshalJSON() ([]byte, error) { - c.inner.LexiconTypeID = "sh.tangled.repo.pull" - return marshalWithRepoDidShadow(c.inner, true) -} - -func (c *pullWrapper) MarshalCBOR(w io.Writer) error { - return c.inner.MarshalCBOR(w) -} - -func marshalWithRepoDidShadow(inner any, nestedTarget bool) ([]byte, error) { - raw, err := json.Marshal(inner) - if err != nil { - return nil, err - } - var top map[string]json.RawMessage - if err := json.Unmarshal(raw, &top); err != nil { - return raw, nil - } - if nestedTarget { - injectIntoNested(top, "target") - injectIntoNested(top, "source") - } else { - addRepoDidShadow(top) - } - return json.Marshal(top) -} - -func injectIntoNested(parent map[string]json.RawMessage, key string) { - raw, ok := parent[key] - if !ok { - return - } - var nested map[string]json.RawMessage - if err := json.Unmarshal(raw, &nested); err != nil { - return - } - addRepoDidShadow(nested) - if reb, err := json.Marshal(nested); err == nil { - parent[key] = reb - } -} - -func addRepoDidShadow(m map[string]json.RawMessage) { - if _, has := m["repoDid"]; has { - return - } - if v, ok := m["repo"]; ok { - m["repoDid"] = v - } -} diff --git a/appview/compat113/compat_test.go b/appview/compat113/compat_test.go deleted file mode 100644 --- a/appview/compat113/compat_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package compat113 - -import ( - "encoding/json" - "testing" - - "tangled.org/core/api/tangled" -) - -func ptr[T any](v T) *T { return &v } - -func TestCollaboratorShadowsRepoDid(t *testing.T) { - rec := &tangled.RepoCollaborator{ - CreatedAt: "2026-05-08T00:00:00Z", - Repo: "did:plc:abalone", - Subject: "did:plc:limpet", - } - - out, err := json.Marshal(Collaborator(rec)) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var got map[string]any - if err := json.Unmarshal(out, &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if got["$type"] != "sh.tangled.repo.collaborator" { - t.Errorf("$type = %v, want sh.tangled.repo.collaborator", got["$type"]) - } - if got["repo"] != "did:plc:abalone" { - t.Errorf("repo = %v, want did:plc:abalone", got["repo"]) - } - if got["repoDid"] != "did:plc:abalone" { - t.Errorf("repoDid shadow missing or wrong: got %v", got["repoDid"]) - } -} - -func TestPullShadowsTargetRepoDid(t *testing.T) { - rec := &tangled.RepoPull{ - CreatedAt: "2026-05-08T00:00:00Z", - Title: "rename whelk handler", - Target: &tangled.RepoPull_Target{ - Branch: "main", - Repo: "did:plc:scallop", - }, - Source: &tangled.RepoPull_Source{ - Branch: "feature-1", - }, - } - - out, err := json.Marshal(Pull(rec)) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var got map[string]any - if err := json.Unmarshal(out, &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - target, ok := got["target"].(map[string]any) - if !ok { - t.Fatalf("target missing or wrong type: %v", got["target"]) - } - if target["repo"] != "did:plc:scallop" { - t.Errorf("target.repo = %v", target["repo"]) - } - if target["repoDid"] != "did:plc:scallop" { - t.Errorf("target.repoDid shadow missing: %v", target["repoDid"]) - } - - if _, has := got["repoDid"]; has { - t.Errorf("top-level repoDid should not be set on pull: %v", got["repoDid"]) - } -} - -func TestPullShadowsForkSourceRepoDid(t *testing.T) { - rec := &tangled.RepoPull{ - CreatedAt: "2026-05-08T00:00:00Z", - Title: "fork-based PR", - Target: &tangled.RepoPull_Target{ - Branch: "main", - Repo: "did:plc:scallop", - }, - Source: &tangled.RepoPull_Source{ - Branch: "feature-2", - Repo: ptr("did:plc:periwinkle"), - }, - } - - out, err := json.Marshal(Pull(rec)) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var got map[string]any - if err := json.Unmarshal(out, &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - source, ok := got["source"].(map[string]any) - if !ok { - t.Fatalf("source missing: %v", got["source"]) - } - if source["repo"] != "did:plc:periwinkle" { - t.Errorf("source.repo = %v", source["repo"]) - } - if source["repoDid"] != "did:plc:periwinkle" { - t.Errorf("source.repoDid shadow missing: %v", source["repoDid"]) - } -} diff --git a/appview/compat113/version.go b/appview/compat113/version.go deleted file mode 100644 --- a/appview/compat113/version.go +++ /dev/null @@ -1,60 +0,0 @@ -package compat113 - -import ( - "context" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - indigoxrpc "github.com/bluesky-social/indigo/xrpc" - "tangled.org/core/api/tangled" -) - -const versionProbeTimeout = 5 * time.Second - -func KnotSupports114(ctx context.Context, host string, dev bool) bool { - scheme := "https" - if dev { - scheme = "http" - } - client := &indigoxrpc.Client{ - Host: fmt.Sprintf("%s://%s", scheme, host), - Client: &http.Client{Timeout: versionProbeTimeout}, - } - - ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout) - defer cancel() - - resp, err := tangled.KnotVersion(ctx, client) - if err != nil || resp == nil { - return true - } - return atLeast114(resp.Version) -} - -func atLeast114(v string) bool { - v = strings.TrimSpace(v) - v = strings.TrimPrefix(v, "v") - if strings.HasPrefix(v, "(devel)") { - return true - } - if v == "" { - return false - } - parts := strings.SplitN(v, ".", 3) - if len(parts) < 2 { - return false - } - major, err := strconv.Atoi(parts[0]) - if err != nil { - return false - } - minorRaw := strings.SplitN(parts[1], "-", 2)[0] - minor, err := strconv.Atoi(minorRaw) - if err != nil { - return false - } - return major > 1 || (major == 1 && minor >= 14) -} diff --git a/appview/compat113/version_test.go b/appview/compat113/version_test.go deleted file mode 100644 --- a/appview/compat113/version_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package compat113 - -import "testing" - -func TestAtLeast114(t *testing.T) { - cases := []struct { - in string - want bool - }{ - {"v1.14.0", true}, - {"v1.14.0-alpha", true}, - {"v1.14.5", true}, - {"v1.13.0", false}, - {"v1.13.0-alpha", false}, - {"v1.0.0", false}, - {"v2.0.0", true}, - {"1.14.0", true}, - {"1.13.99", false}, - {"(devel)", true}, - {"", false}, - {"garbagio-furioso", false}, - {"v1", false}, - {"vX.Y.Z", false}, - {"unknown", false}, - {"unknown-abc1234", false}, - {"unknown-abc1234-modified", false}, - } - for _, c := range cases { - t.Run(c.in, func(t *testing.T) { - if got := atLeast114(c.in); got != c.want { - t.Errorf("atLeast114(%q) = %v, want %v", c.in, got, c.want) - } - }) - } -} diff --git a/appview/knotcompat/compat.go b/appview/knotcompat/compat.go new file mode 100644 --- /dev/null +++ b/appview/knotcompat/compat.go @@ -0,0 +1,87 @@ +package knotcompat + +import ( + "encoding/json" + "io" + + lexutil "github.com/bluesky-social/indigo/lex/util" + "tangled.org/core/api/tangled" +) + +func Collaborator(r *tangled.RepoCollaborator) *lexutil.LexiconTypeDecoder { + return &lexutil.LexiconTypeDecoder{Val: &collaboratorWrapper{inner: r}} +} + +func Pull(r *tangled.RepoPull) *lexutil.LexiconTypeDecoder { + return &lexutil.LexiconTypeDecoder{Val: &pullWrapper{inner: r}} +} + +type collaboratorWrapper struct { + LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.collaborator"` + inner *tangled.RepoCollaborator +} + +func (c *collaboratorWrapper) MarshalJSON() ([]byte, error) { + c.inner.LexiconTypeID = "sh.tangled.repo.collaborator" + return marshalWithRepoDidShadow(c.inner, false) +} + +func (c *collaboratorWrapper) MarshalCBOR(w io.Writer) error { + return c.inner.MarshalCBOR(w) +} + +type pullWrapper struct { + LexiconTypeID string `cborgen:"$type,const=sh.tangled.repo.pull"` + inner *tangled.RepoPull +} + +func (c *pullWrapper) MarshalJSON() ([]byte, error) { + c.inner.LexiconTypeID = "sh.tangled.repo.pull" + return marshalWithRepoDidShadow(c.inner, true) +} + +func (c *pullWrapper) MarshalCBOR(w io.Writer) error { + return c.inner.MarshalCBOR(w) +} + +func marshalWithRepoDidShadow(inner any, nestedTarget bool) ([]byte, error) { + raw, err := json.Marshal(inner) + if err != nil { + return nil, err + } + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + return raw, nil + } + if nestedTarget { + injectIntoNested(top, "target") + injectIntoNested(top, "source") + } else { + addRepoDidShadow(top) + } + return json.Marshal(top) +} + +func injectIntoNested(parent map[string]json.RawMessage, key string) { + raw, ok := parent[key] + if !ok { + return + } + var nested map[string]json.RawMessage + if err := json.Unmarshal(raw, &nested); err != nil { + return + } + addRepoDidShadow(nested) + if reb, err := json.Marshal(nested); err == nil { + parent[key] = reb + } +} + +func addRepoDidShadow(m map[string]json.RawMessage) { + if _, has := m["repoDid"]; has { + return + } + if v, ok := m["repo"]; ok { + m["repoDid"] = v + } +} diff --git a/appview/knotcompat/compat_test.go b/appview/knotcompat/compat_test.go new file mode 100644 --- /dev/null +++ b/appview/knotcompat/compat_test.go @@ -0,0 +1,113 @@ +package knotcompat + +import ( + "encoding/json" + "testing" + + "tangled.org/core/api/tangled" +) + +func ptr[T any](v T) *T { return &v } + +func TestCollaboratorShadowsRepoDid(t *testing.T) { + rec := &tangled.RepoCollaborator{ + CreatedAt: "2026-05-08T00:00:00Z", + Repo: "did:plc:abalone", + Subject: "did:plc:limpet", + } + + out, err := json.Marshal(Collaborator(rec)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got["$type"] != "sh.tangled.repo.collaborator" { + t.Errorf("$type = %v, want sh.tangled.repo.collaborator", got["$type"]) + } + if got["repo"] != "did:plc:abalone" { + t.Errorf("repo = %v, want did:plc:abalone", got["repo"]) + } + if got["repoDid"] != "did:plc:abalone" { + t.Errorf("repoDid shadow missing or wrong: got %v", got["repoDid"]) + } +} + +func TestPullShadowsTargetRepoDid(t *testing.T) { + rec := &tangled.RepoPull{ + CreatedAt: "2026-05-08T00:00:00Z", + Title: "rename whelk handler", + Target: &tangled.RepoPull_Target{ + Branch: "main", + Repo: "did:plc:scallop", + }, + Source: &tangled.RepoPull_Source{ + Branch: "feature-1", + }, + } + + out, err := json.Marshal(Pull(rec)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + target, ok := got["target"].(map[string]any) + if !ok { + t.Fatalf("target missing or wrong type: %v", got["target"]) + } + if target["repo"] != "did:plc:scallop" { + t.Errorf("target.repo = %v", target["repo"]) + } + if target["repoDid"] != "did:plc:scallop" { + t.Errorf("target.repoDid shadow missing: %v", target["repoDid"]) + } + + if _, has := got["repoDid"]; has { + t.Errorf("top-level repoDid should not be set on pull: %v", got["repoDid"]) + } +} + +func TestPullShadowsForkSourceRepoDid(t *testing.T) { + rec := &tangled.RepoPull{ + CreatedAt: "2026-05-08T00:00:00Z", + Title: "fork-based PR", + Target: &tangled.RepoPull_Target{ + Branch: "main", + Repo: "did:plc:scallop", + }, + Source: &tangled.RepoPull_Source{ + Branch: "feature-2", + Repo: ptr("did:plc:periwinkle"), + }, + } + + out, err := json.Marshal(Pull(rec)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + source, ok := got["source"].(map[string]any) + if !ok { + t.Fatalf("source missing: %v", got["source"]) + } + if source["repo"] != "did:plc:periwinkle" { + t.Errorf("source.repo = %v", source["repo"]) + } + if source["repoDid"] != "did:plc:periwinkle" { + t.Errorf("source.repoDid shadow missing: %v", source["repoDid"]) + } +} diff --git a/appview/knotcompat/version.go b/appview/knotcompat/version.go new file mode 100644 --- /dev/null +++ b/appview/knotcompat/version.go @@ -0,0 +1,230 @@ +package knotcompat + +import ( + "context" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "tangled.org/core/api/tangled" + "tangled.org/core/consts" +) + +const ( + versionProbeTimeout = 5 * time.Second + versionProbeFresh = 5 * time.Minute + versionProbeTrust = time.Hour + versionProbeCacheMax = 4096 +) + +type versionProbeEntry struct { + version string + probedAt time.Time +} + +type versionProbeCache struct { + mu sync.Mutex + entries map[string]versionProbeEntry +} + +func (c *versionProbeCache) get(host string) (versionProbeEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[host] + return e, ok +} + +func (c *versionProbeCache) put(host, version string, at time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.entries[host]; !exists && len(c.entries) >= versionProbeCacheMax { + c.evictLocked(at) + } + c.entries[host] = versionProbeEntry{version: version, probedAt: at} +} + +func (c *versionProbeCache) evictLocked(now time.Time) { + oldestHost := "" + var oldestAt time.Time + for h, e := range c.entries { + if now.Sub(e.probedAt) >= versionProbeTrust { + delete(c.entries, h) + continue + } + if oldestHost == "" || e.probedAt.Before(oldestAt) { + oldestHost, oldestAt = h, e.probedAt + } + } + if len(c.entries) >= versionProbeCacheMax && oldestHost != "" { + delete(c.entries, oldestHost) + } +} + +func (c *versionProbeCache) supports(now time.Time, host string, minMajor, minMinor int, failOpen bool, probe func() (string, bool)) bool { + if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeFresh { + return atLeast(e.version, minMajor, minMinor) + } + if version, ok := probe(); ok { + c.put(host, version, now) + return atLeast(version, minMajor, minMinor) + } + if e, ok := c.get(host); ok && now.Sub(e.probedAt) < versionProbeTrust { + return atLeast(e.version, minMajor, minMinor) + } + return failOpen +} + +var probeCache = &versionProbeCache{entries: map[string]versionProbeEntry{}} + +type NativeLatch interface { + IsNative(host string) bool + MarkNative(host string) +} + +type latchBox struct{ l NativeLatch } + +type nativeGate struct { + memo sync.Map + negMemo sync.Map + latch atomic.Pointer[latchBox] + useOnce sync.Once + now func() time.Time +} + +func (g *nativeGate) clock() time.Time { + if g.now != nil { + return g.now() + } + return time.Now() +} + +func (g *nativeGate) use(l NativeLatch) { + g.useOnce.Do(func() { + g.latch.Store(&latchBox{l: l}) + }) +} + +func (g *nativeGate) currentLatch() NativeLatch { + if b := g.latch.Load(); b != nil { + return b.l + } + return nil +} + +func (g *nativeGate) isNative(host string, probe func() bool) bool { + if _, ok := g.memo.Load(host); ok { + return true + } + if until, ok := g.negMemo.Load(host); ok { + if g.clock().Before(until.(time.Time)) { + return false + } + g.negMemo.Delete(host) + } + if l := g.currentLatch(); l != nil && l.IsNative(host) { + g.memo.Store(host, struct{}{}) + return true + } + if !probe() { + g.negMemo.Store(host, g.clock().Add(versionProbeFresh)) + return false + } + g.memo.Store(host, struct{}{}) + if l := g.currentLatch(); l != nil { + l.MarkNative(host) + } + return true +} + +var nativeProbeGate = &nativeGate{} + +func UseNativeLatch(l NativeLatch) { + nativeProbeGate.use(l) +} + +func KnotSupports114(ctx context.Context, host string, dev bool) bool { + return knotSupportsVersion(ctx, host, dev, 1, 14, true) +} + +func KnotHasCapability(ctx context.Context, host string, dev bool, capability consts.Capability) bool { + return nativeProbeGate.isNative(host, func() bool { + return knotDeclares(ctx, host, dev, capability) + }) +} + +func knotDeclares(ctx context.Context, host string, dev bool, capability consts.Capability) bool { + scheme := "https" + if dev { + scheme = "http" + } + client := &indigoxrpc.Client{ + Host: fmt.Sprintf("%s://%s", scheme, host), + Client: &http.Client{Timeout: versionProbeTimeout}, + } + + ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout) + defer cancel() + + resp, err := tangled.KnotVersion(ctx, client) + if err != nil || resp == nil { + return false + } + return slices.Contains(resp.Capabilities, string(capability)) +} + +func knotSupportsVersion(ctx context.Context, host string, dev bool, minMajor, minMinor int, failOpen bool) bool { + return probeCache.supports(time.Now(), host, minMajor, minMinor, failOpen, func() (string, bool) { + return probeVersion(ctx, host, dev) + }) +} + +func probeVersion(ctx context.Context, host string, dev bool) (string, bool) { + scheme := "https" + if dev { + scheme = "http" + } + client := &indigoxrpc.Client{ + Host: fmt.Sprintf("%s://%s", scheme, host), + Client: &http.Client{Timeout: versionProbeTimeout}, + } + + ctx, cancel := context.WithTimeout(ctx, versionProbeTimeout) + defer cancel() + + resp, err := tangled.KnotVersion(ctx, client) + if err != nil || resp == nil { + return "", false + } + return resp.Version, true +} + +func atLeast(v string, minMajor, minMinor int) bool { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + if strings.HasPrefix(v, "(devel)") { + return true + } + if v == "" { + return false + } + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return false + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + minorRaw := strings.SplitN(parts[1], "-", 2)[0] + minor, err := strconv.Atoi(minorRaw) + if err != nil { + return false + } + return major > minMajor || (major == minMajor && minor >= minMinor) +} -- tangled.sh