Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

View File

@@ -0,0 +1,17 @@
# See services/iam/Dockerfile for the pattern explanation.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/conversation/ services/conversation/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/conversation-server ./services/conversation/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/conversation-server ./conversation-server
COPY --from=build /src/services/conversation/migrations ./migrations
ENV ENKLY_CONVERSATION_GRPC_ADDR=:9003
EXPOSE 9003
ENTRYPOINT ["./conversation-server"]

View File

@@ -0,0 +1,98 @@
// Command server runs the conversation microservice: a gRPC server
// exposing api/proto/conversation/v1, backed by its own Postgres
// database. Replaces internal/db/conversations.go's callers.
package main
import (
"context"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
conversationv1 "enkly/gen/conversation/v1"
"enkly/pkg/envkit"
"enkly/pkg/eventbus"
"enkly/pkg/pgkit"
"enkly/services/conversation/internal/application"
"enkly/services/conversation/internal/infrastructure/postgres"
grpcsrv "enkly/services/conversation/internal/interfaces/grpc"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
if err := envkit.LoadDotEnv(".env"); err != nil {
logger.Error("load .env", "err", err)
os.Exit(1)
}
var (
grpcAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", ":9003")
postgresDSN = envkit.String("ENKLY_CONVERSATION_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_conversation?sslmode=disable")
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
migrationsPath = envkit.String("ENKLY_CONVERSATION_MIGRATIONS", "migrations/0001_init.sql")
)
db, err := pgkit.Open(postgresDSN)
if err != nil {
logger.Error("open postgres", "err", err)
os.Exit(1)
}
defer db.Close()
migrationSQL, err := os.ReadFile(migrationsPath)
if err != nil {
logger.Error("read migrations", "path", migrationsPath, "err", err)
os.Exit(1)
}
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
logger.Error("migrate", "err", err)
os.Exit(1)
}
bus, err := eventbus.Connect(rabbitURL, logger)
if err != nil {
logger.Error("connect rabbitmq", "err", err)
os.Exit(1)
}
defer bus.Close()
svc := &application.Service{
Conversations: postgres.NewConversationRepo(db),
Messages: postgres.NewMessageRepo(db),
Events: bus,
}
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
conversationv1.RegisterConversationServiceServer(grpcServer, grpcsrv.New(svc))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("conversation service listening", "addr", grpcAddr)
if err := grpcServer.Serve(lis); err != nil {
logger.Error("serve", "err", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
logger.Info("shutting down")
grpcServer.GracefulStop()
}

View File

@@ -0,0 +1,75 @@
// Package application holds conversation's use cases. Thin by design —
// the monolith's own conversation handling was mostly direct db calls
// with little business logic beyond ownership scoping, which the
// repository layer already enforces (see domain.ConversationRepository's
// doc comment).
package application
import (
"context"
"enkly/pkg/eventbus"
"enkly/pkg/eventbus/events"
"enkly/services/conversation/internal/domain"
)
type Publisher interface {
PublishBestEffort(ctx context.Context, routingKey string, payload any)
}
type Service struct {
Conversations domain.ConversationRepository
Messages domain.MessageRepository
Events Publisher
}
func (s *Service) CreateConversation(ctx context.Context, userID int64, title string) (*domain.Conversation, error) {
return s.Conversations.Create(ctx, userID, title)
}
func (s *Service) GetConversation(ctx context.Context, userID, id int64) (*domain.Conversation, error) {
return s.Conversations.Get(ctx, userID, id)
}
func (s *Service) ListConversations(ctx context.Context, userID int64) ([]*domain.Conversation, error) {
return s.Conversations.List(ctx, userID)
}
func (s *Service) MostRecentConversation(ctx context.Context, userID int64) (*domain.Conversation, error) {
return s.Conversations.MostRecent(ctx, userID)
}
func (s *Service) RenameConversation(ctx context.Context, userID, id int64, title string) (*domain.Conversation, error) {
return s.Conversations.Rename(ctx, userID, id, title)
}
func (s *Service) AutoTitleConversation(ctx context.Context, userID, id int64, title string) (bool, error) {
return s.Conversations.AutoTitle(ctx, userID, id, title)
}
func (s *Service) DeleteConversation(ctx context.Context, userID, id int64) error {
return s.Conversations.Delete(ctx, userID, id)
}
func (s *Service) CountMessages(ctx context.Context, conversationID int64) (int, error) {
return s.Conversations.CountMessages(ctx, conversationID)
}
func (s *Service) AddMessage(ctx context.Context, conversationID int64, role domain.Role, content, toolName string) (*domain.Message, error) {
m, err := s.Messages.Add(ctx, conversationID, role, content, toolName)
if err != nil {
return nil, err
}
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyMessageCreated, events.MessageCreated{
ConversationID: conversationID, MessageID: m.ID, Role: string(role),
})
return m, nil
}
func (s *Service) ListMessages(ctx context.Context, conversationID int64) ([]*domain.Message, error) {
return s.Messages.List(ctx, conversationID)
}
func (s *Service) AddToolCall(ctx context.Context, tc domain.ToolCall) error {
return s.Messages.AddToolCall(ctx, tc)
}

View File

@@ -0,0 +1,70 @@
// Package domain is the Conversation bounded context's entities and
// repository interfaces — transplanted from internal/db/conversations.go.
package domain
import (
"context"
"errors"
"time"
)
var ErrNotFound = errors.New("conversation: not found")
type Conversation struct {
ID int64
UserID int64
Title string
CreatedAt time.Time
LastMessageAt time.Time
}
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
RoleSystem Role = "system"
RoleTool Role = "tool"
)
type Message struct {
ID int64
ConversationID int64
Role Role
Content string
ToolName string
CreatedAt time.Time
}
type ToolCall struct {
MessageID int64
ToolName string
ArgumentsJSON string
Result string
IsError bool
}
// ConversationRepository persists Conversation rows, scoped to a
// user_id on every read/write the same way db.conversations.go always
// did — a conversation belonging to another user is simply ErrNotFound,
// never a separate "forbidden" case, matching the monolith's behavior.
type ConversationRepository interface {
Create(ctx context.Context, userID int64, title string) (*Conversation, error)
Get(ctx context.Context, userID, conversationID int64) (*Conversation, error)
List(ctx context.Context, userID int64) ([]*Conversation, error)
MostRecent(ctx context.Context, userID int64) (*Conversation, error)
Rename(ctx context.Context, userID, conversationID int64, title string) (*Conversation, error)
// AutoTitle sets title only if the conversation's title still
// equals the default ("New conversation"), reporting whether it
// actually changed it.
AutoTitle(ctx context.Context, userID, conversationID int64, title string) (bool, error)
Delete(ctx context.Context, userID, conversationID int64) error
CountMessages(ctx context.Context, conversationID int64) (int, error)
}
// MessageRepository persists Message and ToolCall rows.
type MessageRepository interface {
Add(ctx context.Context, conversationID int64, role Role, content, toolName string) (*Message, error)
List(ctx context.Context, conversationID int64) ([]*Message, error)
AddToolCall(ctx context.Context, tc ToolCall) error
}

View File

@@ -0,0 +1,125 @@
// Package postgres implements conversation's domain repositories
// against the conversation database. One-for-one transplant of
// internal/db/conversations.go.
package postgres
import (
"context"
"database/sql"
"errors"
"enkly/pkg/pgkit"
"enkly/services/conversation/internal/domain"
)
// DefaultConversationTitle matches the column default in
// migrations/0001_init.sql.
const DefaultConversationTitle = "New conversation"
const conversationSelect = `
SELECT c.id, c.user_id, c.title, c.created_at, COALESCE(lm.last, c.created_at)
FROM conversations c
LEFT JOIN LATERAL (
SELECT max(m.created_at) AS last FROM messages m WHERE m.conversation_id = c.id
) lm ON true`
func scanConversation(row interface{ Scan(...any) error }) (*domain.Conversation, error) {
var c domain.Conversation
if err := row.Scan(&c.ID, &c.UserID, &c.Title, &c.CreatedAt, &c.LastMessageAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
return &c, nil
}
type ConversationRepo struct {
DB *pgkit.DB
}
func NewConversationRepo(db *pgkit.DB) *ConversationRepo { return &ConversationRepo{DB: db} }
func (r *ConversationRepo) Create(ctx context.Context, userID int64, title string) (*domain.Conversation, error) {
if title == "" {
title = DefaultConversationTitle
}
var id int64
if err := r.DB.QueryRowContext(ctx,
`INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING id`,
userID, title).Scan(&id); err != nil {
return nil, err
}
return r.Get(ctx, userID, id)
}
func (r *ConversationRepo) Get(ctx context.Context, userID, id int64) (*domain.Conversation, error) {
return scanConversation(r.DB.QueryRowContext(ctx,
conversationSelect+` WHERE c.id = $1 AND c.user_id = $2`, id, userID))
}
func (r *ConversationRepo) List(ctx context.Context, userID int64) ([]*domain.Conversation, error) {
rows, err := r.DB.QueryContext(ctx,
conversationSelect+` WHERE c.user_id = $1 ORDER BY 5 DESC, c.id DESC LIMIT 200`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*domain.Conversation
for rows.Next() {
c, err := scanConversation(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *ConversationRepo) MostRecent(ctx context.Context, userID int64) (*domain.Conversation, error) {
return scanConversation(r.DB.QueryRowContext(ctx,
conversationSelect+` WHERE c.user_id = $1 ORDER BY 5 DESC, c.id DESC LIMIT 1`, userID))
}
func (r *ConversationRepo) Rename(ctx context.Context, userID, id int64, title string) (*domain.Conversation, error) {
res, err := r.DB.ExecContext(ctx,
`UPDATE conversations SET title = $3 WHERE id = $1 AND user_id = $2`, id, userID, title)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, domain.ErrNotFound
}
return r.Get(ctx, userID, id)
}
func (r *ConversationRepo) AutoTitle(ctx context.Context, userID, id int64, title string) (bool, error) {
res, err := r.DB.ExecContext(ctx,
`UPDATE conversations SET title = $3 WHERE id = $1 AND user_id = $2 AND title = $4`,
id, userID, title, DefaultConversationTitle)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func (r *ConversationRepo) Delete(ctx context.Context, userID, id int64) error {
res, err := r.DB.ExecContext(ctx,
`DELETE FROM conversations WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *ConversationRepo) CountMessages(ctx context.Context, conversationID int64) (int, error) {
var n int
err := r.DB.QueryRowContext(ctx,
`SELECT count(*) FROM messages WHERE conversation_id = $1`, conversationID).Scan(&n)
return n, err
}

View File

@@ -0,0 +1,76 @@
package postgres
import (
"context"
"fmt"
"strings"
"enkly/pkg/pgkit"
"enkly/services/conversation/internal/domain"
)
// cleanText makes arbitrary tool/model output safe for a Postgres TEXT
// column, which rejects NUL bytes and invalid UTF-8 outright.
func cleanText(s string) string {
if strings.IndexByte(s, 0) >= 0 {
s = strings.ReplaceAll(s, "\x00", "")
}
return strings.ToValidUTF8(s, "\uFFFD")
}
type MessageRepo struct {
DB *pgkit.DB
}
func NewMessageRepo(db *pgkit.DB) *MessageRepo { return &MessageRepo{DB: db} }
func (r *MessageRepo) Add(ctx context.Context, conversationID int64, role domain.Role, content, toolName string) (*domain.Message, error) {
var tool any
if toolName != "" {
tool = toolName
}
var m domain.Message
err := r.DB.QueryRowContext(ctx, `
INSERT INTO messages (conversation_id, role, content, tool_name)
VALUES ($1, $2, $3, $4) RETURNING id, conversation_id, role, content, COALESCE(tool_name, ''), created_at`,
conversationID, string(role), cleanText(content), tool).
Scan(&m.ID, &m.ConversationID, &m.Role, &m.Content, &m.ToolName, &m.CreatedAt)
if err != nil {
return nil, fmt.Errorf("conversation: add message: %w", err)
}
return &m, nil
}
func (r *MessageRepo) List(ctx context.Context, conversationID int64) ([]*domain.Message, error) {
rows, err := r.DB.QueryContext(ctx, `
SELECT id, conversation_id, role, content, COALESCE(tool_name, ''), created_at
FROM messages WHERE conversation_id = $1 ORDER BY created_at, id`, conversationID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*domain.Message
for rows.Next() {
var m domain.Message
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Content, &m.ToolName, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, &m)
}
return out, rows.Err()
}
func (r *MessageRepo) AddToolCall(ctx context.Context, tc domain.ToolCall) error {
argsJSON := tc.ArgumentsJSON
if argsJSON == "" {
argsJSON = "{}"
}
// jsonb rejects the \u0000 escape that encoding/json emits for NUL.
argsJSON = strings.ReplaceAll(argsJSON, `\u0000`, "")
_, err := r.DB.ExecContext(ctx, `
INSERT INTO tool_calls (message_id, tool_name, arguments, result, is_error)
VALUES ($1, $2, $3::jsonb, $4, $5)`,
tc.MessageID, tc.ToolName, argsJSON, cleanText(tc.Result), tc.IsError)
return err
}

View File

@@ -0,0 +1,179 @@
package grpc
import (
"context"
"errors"
"google.golang.org/protobuf/types/known/timestamppb"
conversationv1 "enkly/gen/conversation/v1"
"enkly/pkg/grpcerr"
"enkly/services/conversation/internal/application"
"enkly/services/conversation/internal/domain"
)
type Server struct {
conversationv1.UnimplementedConversationServiceServer
Svc *application.Service
}
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
func toProtoRole(r domain.Role) conversationv1.Role {
switch r {
case domain.RoleUser:
return conversationv1.Role_ROLE_USER
case domain.RoleAssistant:
return conversationv1.Role_ROLE_ASSISTANT
case domain.RoleSystem:
return conversationv1.Role_ROLE_SYSTEM
case domain.RoleTool:
return conversationv1.Role_ROLE_TOOL
default:
return conversationv1.Role_ROLE_UNSPECIFIED
}
}
func fromProtoRole(r conversationv1.Role) domain.Role {
switch r {
case conversationv1.Role_ROLE_USER:
return domain.RoleUser
case conversationv1.Role_ROLE_ASSISTANT:
return domain.RoleAssistant
case conversationv1.Role_ROLE_SYSTEM:
return domain.RoleSystem
case conversationv1.Role_ROLE_TOOL:
return domain.RoleTool
default:
return domain.RoleUser
}
}
func toProtoConversation(c *domain.Conversation) *conversationv1.Conversation {
return &conversationv1.Conversation{
Id: c.ID,
UserId: c.UserID,
Title: c.Title,
CreatedAt: timestamppb.New(c.CreatedAt),
LastMessageAt: timestamppb.New(c.LastMessageAt),
}
}
func toProtoMessage(m *domain.Message) *conversationv1.Message {
return &conversationv1.Message{
Id: m.ID,
ConversationId: m.ConversationID,
Role: toProtoRole(m.Role),
Content: m.Content,
ToolName: m.ToolName,
CreatedAt: timestamppb.New(m.CreatedAt),
}
}
func mapErr(err error) error {
if errors.Is(err, domain.ErrNotFound) {
return grpcerr.NotFound("conversation")
}
return grpcerr.Internal(err)
}
func (s *Server) CreateConversation(ctx context.Context, req *conversationv1.CreateConversationRequest) (*conversationv1.Conversation, error) {
c, err := s.Svc.CreateConversation(ctx, req.GetUserId(), req.GetTitle())
if err != nil {
return nil, mapErr(err)
}
return toProtoConversation(c), nil
}
func (s *Server) GetConversation(ctx context.Context, req *conversationv1.GetConversationRequest) (*conversationv1.Conversation, error) {
c, err := s.Svc.GetConversation(ctx, req.GetUserId(), req.GetConversationId())
if err != nil {
return nil, mapErr(err)
}
return toProtoConversation(c), nil
}
func (s *Server) ListConversations(ctx context.Context, req *conversationv1.ListConversationsRequest) (*conversationv1.ListConversationsResponse, error) {
list, err := s.Svc.ListConversations(ctx, req.GetUserId())
if err != nil {
return nil, grpcerr.Internal(err)
}
resp := &conversationv1.ListConversationsResponse{}
for _, c := range list {
resp.Conversations = append(resp.Conversations, toProtoConversation(c))
}
return resp, nil
}
func (s *Server) MostRecentConversation(ctx context.Context, req *conversationv1.MostRecentConversationRequest) (*conversationv1.Conversation, error) {
c, err := s.Svc.MostRecentConversation(ctx, req.GetUserId())
if err != nil {
return nil, mapErr(err)
}
return toProtoConversation(c), nil
}
func (s *Server) RenameConversation(ctx context.Context, req *conversationv1.RenameConversationRequest) (*conversationv1.Conversation, error) {
c, err := s.Svc.RenameConversation(ctx, req.GetUserId(), req.GetConversationId(), req.GetTitle())
if err != nil {
return nil, mapErr(err)
}
return toProtoConversation(c), nil
}
func (s *Server) AutoTitleConversation(ctx context.Context, req *conversationv1.AutoTitleConversationRequest) (*conversationv1.AutoTitleConversationResponse, error) {
changed, err := s.Svc.AutoTitleConversation(ctx, req.GetUserId(), req.GetConversationId(), req.GetTitle())
if err != nil {
return nil, grpcerr.Internal(err)
}
return &conversationv1.AutoTitleConversationResponse{Retitled: changed}, nil
}
func (s *Server) DeleteConversation(ctx context.Context, req *conversationv1.DeleteConversationRequest) (*conversationv1.DeleteConversationResponse, error) {
if err := s.Svc.DeleteConversation(ctx, req.GetUserId(), req.GetConversationId()); err != nil {
return nil, mapErr(err)
}
return &conversationv1.DeleteConversationResponse{}, nil
}
func (s *Server) CountMessages(ctx context.Context, req *conversationv1.CountMessagesRequest) (*conversationv1.CountMessagesResponse, error) {
n, err := s.Svc.CountMessages(ctx, req.GetConversationId())
if err != nil {
return nil, grpcerr.Internal(err)
}
return &conversationv1.CountMessagesResponse{Count: int32(n)}, nil
}
func (s *Server) AddMessage(ctx context.Context, req *conversationv1.AddMessageRequest) (*conversationv1.Message, error) {
m, err := s.Svc.AddMessage(ctx, req.GetConversationId(), fromProtoRole(req.GetRole()), req.GetContent(), req.GetToolName())
if err != nil {
return nil, grpcerr.Internal(err)
}
return toProtoMessage(m), nil
}
func (s *Server) ListMessages(ctx context.Context, req *conversationv1.ListMessagesRequest) (*conversationv1.ListMessagesResponse, error) {
list, err := s.Svc.ListMessages(ctx, req.GetConversationId())
if err != nil {
return nil, grpcerr.Internal(err)
}
resp := &conversationv1.ListMessagesResponse{}
for _, m := range list {
resp.Messages = append(resp.Messages, toProtoMessage(m))
}
return resp, nil
}
func (s *Server) AddToolCall(ctx context.Context, req *conversationv1.AddToolCallRequest) (*conversationv1.AddToolCallResponse, error) {
err := s.Svc.AddToolCall(ctx, domain.ToolCall{
MessageID: req.GetMessageId(),
ToolName: req.GetToolName(),
ArgumentsJSON: req.GetArgumentsJson(),
Result: req.GetResult(),
IsError: req.GetIsError(),
})
if err != nil {
return nil, grpcerr.Internal(err)
}
return &conversationv1.AddToolCallResponse{}, nil
}

View File

@@ -0,0 +1,38 @@
-- =====================================================================
-- conversation service — 0001_init.sql
-- Database-per-service: owns `conversations`, `messages`, `tool_calls`.
-- user_id is a bare BIGINT (see iam's migration note on why there's no
-- cross-database foreign key here).
-- =====================================================================
CREATE TABLE IF NOT EXISTS conversations (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
title TEXT NOT NULL DEFAULT 'New conversation',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS conversations_user_id_idx ON conversations (user_id);
CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool', 'system')),
content TEXT NOT NULL,
tool_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS messages_conversation_id_idx ON messages (conversation_id, created_at);
-- Audit log of every tool the agent executed on the host, surfaced in
-- the UI's tool cards and useful for debugging.
CREATE TABLE IF NOT EXISTS tool_calls (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
arguments JSONB NOT NULL,
result TEXT,
is_error BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);