nomadcode/services/core/internal/gitosync/runner.go
toki f4959a3bab feat: gito-protosocket-consumer-wire-readiness milestone completion
- Update roadmap milestone status
- Add gitosync runner and tests
- Update gitoevents tests
- Adjust docker-compose configuration
- Update core and project READMEs
- Add agent-task for consumer wire readiness
2026-06-16 22:26:44 +09:00

170 lines
4.3 KiB
Go

package gitosync
import (
"context"
"errors"
"io"
"log/slog"
"os/exec"
"strings"
"sync"
"nhooyr.io/websocket"
toki "git.toki-labs.com/toki/proto-socket/go"
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
"github.com/nomadcode/nomadcode-core/internal/protosocket"
"google.golang.org/protobuf/types/known/structpb"
)
// ExecCommandRunner is the production CommandRunner backed by os/exec. It runs
// git in the given directory and returns stdout with the trailing newline left
// intact for the caller to trim.
type ExecCommandRunner struct{}
func (ExecCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
// protoSocketTransport is the production gitoevents.Transport that dials the Gito
// proto-socket endpoint over a websocket and exchanges binary protobuf envelopes.
// It is kept unexported; callers wire it through NewRunner.
type protoSocketTransport struct {
url string
conn *websocket.Conn
client *toki.WsClient
inbound chan struct {
env protosocket.Envelope
err error
}
closeOnce sync.Once
closed chan struct{}
inboundCap int
}
func (t *protoSocketTransport) Connect(ctx context.Context) error {
conn, _, err := websocket.Dial(ctx, t.url, nil)
if err != nil {
return err
}
t.conn = conn
t.closed = make(chan struct{})
cap := t.inboundCap
if cap <= 0 {
cap = 100
}
t.client = toki.NewWsClient(conn, 0, 0, protosocket.ParserMap())
t.inbound = make(chan struct {
env protosocket.Envelope
err error
}, cap)
toki.AddListenerTyped[*structpb.Struct](&t.client.Communicator, func(msg *structpb.Struct) {
env, err := protosocket.EnvelopeFromStruct(msg)
t.enqueue(struct {
env protosocket.Envelope
err error
}{env: env, err: err})
})
t.client.AddDisconnectListener(func(c *toki.WsClient) {
t.enqueue(struct {
env protosocket.Envelope
err error
}{err: io.EOF})
})
return nil
}
func (t *protoSocketTransport) enqueue(item struct {
env protosocket.Envelope
err error
}) {
select {
case <-t.closed:
case t.inbound <- item:
}
}
func (t *protoSocketTransport) Write(ctx context.Context, env protosocket.Envelope) error {
if t.client == nil {
return errors.New("gitosync: transport not connected")
}
msg, err := env.ToStruct()
if err != nil {
return err
}
return t.client.Communicator.Send(msg)
}
func (t *protoSocketTransport) Read(ctx context.Context) (protosocket.Envelope, error) {
if t.client == nil {
return protosocket.Envelope{}, errors.New("gitosync: transport not connected")
}
select {
case <-ctx.Done():
return protosocket.Envelope{}, ctx.Err()
case <-t.closed:
return protosocket.Envelope{}, io.EOF
case item, ok := <-t.inbound:
if !ok {
return protosocket.Envelope{}, io.EOF
}
if item.err != nil {
return protosocket.Envelope{}, item.err
}
return item.env, nil
}
}
func (t *protoSocketTransport) Close() error {
var err error
t.closeOnce.Do(func() {
if t.closed != nil {
close(t.closed)
}
if t.client != nil {
err = t.client.Close()
}
})
return err
}
// NewRunner builds the gitoevents client wired to forward on-target branch
// events into the bridge. It returns a *gitoevents.Client whose Run blocks
// until the context is cancelled or the stream ends; the caller owns starting
// it in a goroutine and cancelling the context on shutdown. The bridge's Handle
// error is logged (not propagated) so a single failed event does not tear down
// the long-lived subscription.
func NewRunner(url, repoID, branch string, bridge *Bridge, logger *slog.Logger) (*gitoevents.Client, error) {
if strings.TrimSpace(url) == "" {
return nil, errors.New("gitosync: proto-socket url is required")
}
if bridge == nil {
return nil, errors.New("gitosync: bridge is nil")
}
transport := &protoSocketTransport{url: url}
handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
if err := bridge.Handle(ctx, ev); err != nil {
if logger != nil {
logger.Error("gito creation sync handle failed",
"error", err, "repo_id", ev.RepoID, "after", ev.After)
}
}
}
return gitoevents.NewClient(transport, gitoevents.Options{
RepoID: repoID,
Branch: branch,
Handler: handler,
Logger: logger,
})
}