feat: 初始化 sundynix-agentix 分层式 AI Agent 平台脚手架
5 层 + 1 条 NATS 零拷贝消息总线的 monorepo(Monolith First → Microservices Morph B)。 纵向主干(任务流 + Token 流回流)已真实跑通,横向各层能力为带注释的桩。 已贯通(real code): - sundynix-shared: 共享契约 + JetStream/core NATS 真实收发(bus) + 内嵌 NATS(devnats) + e2e 测试 - sundynix-gateway: Gin 接入 + DSL 解析组装 + NATS Publish + SSE 流式输出 - sundynix-dispatcher: NATS 消费 + Eino Orchestrator 流式回流 + 熔断器 + LLM Pool 占位流式 - 链路: HTTP POST → DSL → sundynix.tasks.* → Dispatcher → Token 经 sundynix.streams.<id> 回流 → SSE - 基础设施: docker-compose(nats/postgres/redis/neo4j/milvus) + Makefile(make demo/e2e) 待填(桩): - Eino 图编排 compose.NewGraph、LLM Pool 接 vLLM/Ollama - Gateway store 换真实 pgx/redis - sundynix-mcp-go: Bleve+Milvus+Neo4j 混合检索 / UniOffice / 外部 API - sundynix-mcp-py: gVisor 沙箱 / MinerU(PaddleOCR) / Docker 解释器 - sundynix-desktop: React Flow 画布 → DSL 导出 → SSE 展示
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// Package bus 封装 NATS JetStream 的连接、流声明、任务发布与消费。
|
||||
// Gateway 与 Dispatcher 共用这套真实收发逻辑,e2e 测试也直接覆盖它。
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
// Bus 持有 NATS 连接与 JetStream 上下文。
|
||||
type Bus struct {
|
||||
nc *nats.Conn
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
// Connect 接入 NATS 骨干网并初始化 JetStream,使用默认重试参数。
|
||||
func Connect(url string) (*Bus, error) {
|
||||
return ConnectWithRetry(url, 30, time.Second)
|
||||
}
|
||||
|
||||
// ConnectWithRetry 在 NATS 暂不可用时按固定间隔重试,容忍服务先于 NATS 启动。
|
||||
func ConnectWithRetry(url string, attempts int, interval time.Duration) (*Bus, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < attempts; i++ {
|
||||
nc, err := nats.Connect(url,
|
||||
nats.Timeout(5*time.Second),
|
||||
nats.RetryOnFailedConnect(true),
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(interval),
|
||||
)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
time.Sleep(interval)
|
||||
continue
|
||||
}
|
||||
// RetryOnFailedConnect 下 Connect 可能立即返回但尚未连上,等待真正建立。
|
||||
if nc.Status() != nats.CONNECTED {
|
||||
if !waitConnected(nc, 5*time.Second) {
|
||||
lastErr = fmt.Errorf("nats not connected within timeout")
|
||||
nc.Close()
|
||||
time.Sleep(interval)
|
||||
continue
|
||||
}
|
||||
}
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, fmt.Errorf("jetstream init: %w", err)
|
||||
}
|
||||
return &Bus{nc: nc, js: js}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("nats connect after %d attempts: %w", attempts, lastErr)
|
||||
}
|
||||
|
||||
func waitConnected(nc *nats.Conn, d time.Duration) bool {
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if nc.Status() == nats.CONNECTED {
|
||||
return true
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return nc.Status() == nats.CONNECTED
|
||||
}
|
||||
|
||||
// Close 关闭底层连接。
|
||||
func (b *Bus) Close() {
|
||||
if b.nc != nil {
|
||||
b.nc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureTaskStream 幂等地创建/更新任务流,捕获 sundynix.tasks.>。
|
||||
func (b *Bus) EnsureTaskStream(ctx context.Context) error {
|
||||
_, err := b.js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
|
||||
Name: contract.StreamTasks,
|
||||
Subjects: []string{contract.SubjectTasksAll},
|
||||
Storage: jetstream.FileStorage,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishTask 把任务发布到 sundynix.tasks.<id>,返回序列号。
|
||||
func (b *Bus) PublishTask(ctx context.Context, t *contract.Task) (uint64, error) {
|
||||
data, err := t.Marshal()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ack, err := b.js.Publish(ctx, contract.TaskSubject(t.ID), data)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("publish task: %w", err)
|
||||
}
|
||||
return ack.Sequence, nil
|
||||
}
|
||||
|
||||
// ---- Token 流回流(core NATS 零拷贝字节管道)----
|
||||
|
||||
// PublishToken 把一个推理 Token 以 core NATS 写到 sundynix.streams.<taskID>。
|
||||
func (b *Bus) PublishToken(taskID string, token []byte) error {
|
||||
return b.nc.Publish(contract.StreamSubject(taskID), token)
|
||||
}
|
||||
|
||||
// CompleteStream 发送 Token 流结束信号(空体 + 结束头)。
|
||||
func (b *Bus) CompleteStream(taskID string) error {
|
||||
msg := nats.NewMsg(contract.StreamSubject(taskID))
|
||||
msg.Header.Set(contract.HeaderStreamEnd, "1")
|
||||
return b.nc.PublishMsg(msg)
|
||||
}
|
||||
|
||||
// SubscribeTokens 订阅某 task 的 Token 流。每个 Token 触发 onToken;
|
||||
// 收到结束信号后触发 onDone。返回的 unsub 用于退订。
|
||||
// 注意:core NATS 无持久化,订阅须在 Token 产生前建立(SSE 客户端先连)。
|
||||
func (b *Bus) SubscribeTokens(taskID string, onToken func([]byte), onDone func()) (unsub func() error, err error) {
|
||||
sub, err := b.nc.Subscribe(contract.StreamSubject(taskID), func(m *nats.Msg) {
|
||||
if m.Header.Get(contract.HeaderStreamEnd) == "1" {
|
||||
onDone()
|
||||
return
|
||||
}
|
||||
// 拷贝,避免 nats 复用底层 buffer。
|
||||
tok := make([]byte, len(m.Data))
|
||||
copy(tok, m.Data)
|
||||
onToken(tok)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subscribe tokens: %w", err)
|
||||
}
|
||||
return sub.Unsubscribe, nil
|
||||
}
|
||||
|
||||
// TaskHandler 处理一个消费到的任务。
|
||||
type TaskHandler func(ctx context.Context, t *contract.Task) error
|
||||
|
||||
// ConsumeTasks 在持久消费者上消费任务,队列组内负载均衡。
|
||||
// 返回的 stop 函数用于优雅停止消费。
|
||||
func (b *Bus) ConsumeTasks(ctx context.Context, h TaskHandler) (stop func(), err error) {
|
||||
cons, err := b.js.CreateOrUpdateConsumer(ctx, contract.StreamTasks, jetstream.ConsumerConfig{
|
||||
Durable: contract.ConsumerDurable,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
FilterSubject: contract.SubjectTasksAll,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create consumer: %w", err)
|
||||
}
|
||||
cc, err := cons.Consume(func(msg jetstream.Msg) {
|
||||
t, err := contract.Unmarshal(msg.Data())
|
||||
if err != nil {
|
||||
_ = msg.Term() // 脏数据,丢弃不重投
|
||||
return
|
||||
}
|
||||
if err := h(ctx, t); err != nil {
|
||||
_ = msg.NakWithDelay(time.Second) // 处理失败,延迟重投
|
||||
return
|
||||
}
|
||||
_ = msg.Ack()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("consume: %w", err)
|
||||
}
|
||||
return cc.Stop, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package bus_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||
natstest "github.com/nats-io/nats-server/v2/test"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/bus"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
// startEmbeddedNATS 启动一个内嵌、开启 JetStream 的 NATS 服务器,免 Docker。
|
||||
func startEmbeddedNATS(t *testing.T) string {
|
||||
t.Helper()
|
||||
opts := natstest.DefaultTestOptions
|
||||
opts.Port = -1 // 随机端口
|
||||
opts.JetStream = true
|
||||
opts.StoreDir = t.TempDir()
|
||||
srv := natstest.RunServer(&opts)
|
||||
if !srv.ReadyForConnections(5 * time.Second) {
|
||||
t.Fatal("embedded NATS not ready")
|
||||
}
|
||||
t.Cleanup(srv.Shutdown)
|
||||
_ = natsserver.Server{} // 触发包引用
|
||||
return srv.ClientURL()
|
||||
}
|
||||
|
||||
// TestTaskRoundTrip 模拟 Gateway 发布 → NATS → Dispatcher 消费 的完整任务流。
|
||||
func TestTaskRoundTrip(t *testing.T) {
|
||||
url := startEmbeddedNATS(t)
|
||||
|
||||
// --- Gateway 侧:连接并声明任务流 ---
|
||||
gw, err := bus.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway connect: %v", err)
|
||||
}
|
||||
defer gw.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := gw.EnsureTaskStream(ctx); err != nil {
|
||||
t.Fatalf("ensure stream: %v", err)
|
||||
}
|
||||
|
||||
// --- Dispatcher 侧:连接并开始消费 ---
|
||||
dp, err := bus.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatcher connect: %v", err)
|
||||
}
|
||||
defer dp.Close()
|
||||
|
||||
got := make(chan *contract.Task, 1)
|
||||
stop, err := dp.ConsumeTasks(ctx, func(_ context.Context, task *contract.Task) error {
|
||||
got <- task
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("consume: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
// --- Gateway 发布一个任务 ---
|
||||
want := &contract.Task{
|
||||
ID: "task_demo_001",
|
||||
Graph: json.RawMessage(`{"nodes":[{"id":"n1","type":"agent"}],"edges":[]}`),
|
||||
Meta: map[string]any{"user": "wt"},
|
||||
}
|
||||
seq, err := gw.PublishTask(ctx, want)
|
||||
if err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
if seq == 0 {
|
||||
t.Fatal("expected non-zero stream sequence")
|
||||
}
|
||||
|
||||
// --- 断言 Dispatcher 收到同一个任务 ---
|
||||
select {
|
||||
case task := <-got:
|
||||
if task.ID != want.ID {
|
||||
t.Fatalf("task id = %q, want %q", task.ID, want.ID)
|
||||
}
|
||||
if task.Meta["user"] != "wt" {
|
||||
t.Fatalf("task meta lost: %+v", task.Meta)
|
||||
}
|
||||
t.Logf("✓ 任务流打通:Gateway publish (seq=%d) → NATS → Dispatcher consume,task_id=%s", seq, task.ID)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout: dispatcher 未收到任务")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenStreamRoundTrip 模拟 Dispatcher 回流 Token → Gateway 订阅 的流式闭环。
|
||||
func TestTokenStreamRoundTrip(t *testing.T) {
|
||||
url := startEmbeddedNATS(t)
|
||||
|
||||
// Gateway 侧:先订阅(core NATS 无持久化,须先连)。
|
||||
gw, err := bus.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway connect: %v", err)
|
||||
}
|
||||
defer gw.Close()
|
||||
|
||||
const taskID = "task_stream_001"
|
||||
var got []string
|
||||
done := make(chan struct{})
|
||||
unsub, err := gw.SubscribeTokens(taskID,
|
||||
func(tok []byte) { got = append(got, string(tok)) },
|
||||
func() { close(done) },
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe tokens: %v", err)
|
||||
}
|
||||
defer func() { _ = unsub() }()
|
||||
|
||||
// Dispatcher 侧:逐 Token 回流后发结束信号。
|
||||
dp, err := bus.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatcher connect: %v", err)
|
||||
}
|
||||
defer dp.Close()
|
||||
|
||||
want := []string{"Hello", " ", "Agent", "!"}
|
||||
for _, tok := range want {
|
||||
if err := dp.PublishToken(taskID, []byte(tok)); err != nil {
|
||||
t.Fatalf("publish token: %v", err)
|
||||
}
|
||||
}
|
||||
if err := dp.CompleteStream(taskID); err != nil {
|
||||
t.Fatalf("complete stream: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
joined := ""
|
||||
for _, s := range got {
|
||||
joined += s
|
||||
}
|
||||
if joined != "Hello Agent!" {
|
||||
t.Fatalf("token stream = %q, want %q", joined, "Hello Agent!")
|
||||
}
|
||||
t.Logf("✓ Token 流闭环:Dispatcher 回流 %d 个 token → Gateway 拼回 %q", len(got), joined)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout: 未收到流结束信号")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Command devnats 启动一个内嵌、开启 JetStream 的本地 NATS 服务器,
|
||||
// 用于无 Docker 环境下的本地联调(生产环境用 deploy/nats 的真实集群)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
storeDir, err := os.MkdirTemp("", "sundynix-jetstream-")
|
||||
if err != nil {
|
||||
log.Fatalf("[devnats] tempdir: %v", err)
|
||||
}
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: 4222,
|
||||
JetStream: true,
|
||||
StoreDir: storeDir,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
log.Fatalf("[devnats] new server: %v", err)
|
||||
}
|
||||
go ns.Start()
|
||||
if !ns.ReadyForConnections(5e9) {
|
||||
log.Fatal("[devnats] not ready")
|
||||
}
|
||||
log.Printf("[devnats] JetStream NATS ready on %s (store=%s)", ns.ClientURL(), storeDir)
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
log.Println("[devnats] shutting down")
|
||||
shutdownQuietly(ns)
|
||||
_ = os.RemoveAll(storeDir)
|
||||
}
|
||||
|
||||
// shutdownQuietly 容忍内嵌 server 退出时偶发的 "close of nil channel" panic。
|
||||
func shutdownQuietly(ns *server.Server) {
|
||||
defer func() { _ = recover() }()
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package contract 是 Gateway / Dispatcher / MCP 之间的共享契约:
|
||||
// Task 数据结构与 NATS subject 命名约定。
|
||||
package contract
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// NATS subject / stream 约定(与 README、各服务 config 保持一致)。
|
||||
const (
|
||||
StreamTasks = "SUNDYNIX_TASKS" // JetStream stream 名
|
||||
SubjectTasks = "sundynix.tasks" // 任务发布主题前缀;实际为 sundynix.tasks.<id>
|
||||
SubjectTasksAll = "sundynix.tasks.>" // stream 捕获的通配
|
||||
SubjectStream = "sundynix.streams" // Token 回流前缀;实际 sundynix.streams.<id>
|
||||
ConsumerDurable = "dispatchers" // Dispatcher 持久消费者(队列组负载均衡)
|
||||
|
||||
// HeaderStreamEnd 是 Token 流的结束信号(core NATS 消息头)。
|
||||
// 置为 "1" 的消息体为空,表示该 task 的 Token 流结束。
|
||||
HeaderStreamEnd = "X-Stream-End"
|
||||
)
|
||||
|
||||
// Task 是 DSL 解析组装后的可调度任务,在 NATS 上以 JSON 传输。
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Graph json.RawMessage `json:"graph"` // React Flow 导出的 Agent 编排图
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// TaskSubject 返回某任务的发布主题。
|
||||
func TaskSubject(id string) string { return SubjectTasks + "." + id }
|
||||
|
||||
// StreamSubject 返回某任务的 Token 回流主题。
|
||||
func StreamSubject(id string) string { return SubjectStream + "." + id }
|
||||
|
||||
// Marshal / Unmarshal 便捷方法。
|
||||
func (t *Task) Marshal() ([]byte, error) { return json.Marshal(t) }
|
||||
func Unmarshal(b []byte) (*Task, error) {
|
||||
var t Task
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
module github.com/sundynix/sundynix-shared
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/nats-io/nats-server/v2 v2.10.20
|
||||
github.com/nats-io/nats.go v1.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/minio/highwayhash v1.0.3 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.5.8 // indirect
|
||||
github.com/nats-io/nkeys v0.4.7 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
golang.org/x/time v0.6.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
|
||||
github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.5.8 h1:uvdSzwWiEGWGXf+0Q+70qv6AQdvcvxrv9hPM0RiPamE=
|
||||
github.com/nats-io/jwt/v2 v2.5.8/go.mod h1:ZdWS1nZa6WMZfFwwgpEaqBV8EPGVgOTDHN/wTbz0Y5A=
|
||||
github.com/nats-io/nats-server/v2 v2.10.20 h1:CXDTYNHeBiAKBTAIP2gjpgbWap2GhATnTLgP8etyvEI=
|
||||
github.com/nats-io/nats-server/v2 v2.10.20/go.mod h1:hgcPnoUtMfxz1qVOvLZGurVypQ+Cg6GXVXjG53iHk+M=
|
||||
github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE=
|
||||
github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8=
|
||||
github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI=
|
||||
github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
Reference in New Issue
Block a user