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

16
services/agent/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# 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/agent/ services/agent/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/agent-server ./services/agent/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/agent-server ./agent-server
ENV ENKLY_AGENT_GRPC_ADDR=:9006
EXPOSE 9006
ENTRYPOINT ["./agent-server"]

View File

@@ -0,0 +1,254 @@
// Command server runs the agent microservice: a gRPC server exposing
// api/proto/agent/v1, orchestrating vLLM-served chat + tool calls
// across the workspace, knowledge, billing and conversation services.
// Replaces internal/llm/agent.go + client.go (chat half) and the
// chat-run sections of internal/handlers/chat.go.
package main
import (
"context"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
agentv1 "enkly/gen/agent/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/authsupport"
"enkly/pkg/effort"
"enkly/pkg/entitlements"
"enkly/pkg/envkit"
"enkly/pkg/vllmkit"
"enkly/services/agent/internal/application"
"enkly/services/agent/internal/domain"
"enkly/services/agent/internal/infrastructure/agentrun"
"enkly/services/agent/internal/infrastructure/grpcclients"
"enkly/services/agent/internal/infrastructure/toolcatalog"
"enkly/services/agent/internal/infrastructure/toolexec"
grpcsrv "enkly/services/agent/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_AGENT_GRPC_ADDR", ":9006")
workspaceAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", "workspace:9004")
knowledgeAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", "knowledge:9005")
billingAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", "billing:9002")
conversationAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", "conversation:9003")
// vllmChatURL / vllmEmbedURL point at the two vLLM
// OpenAI-compatible deployments this service calls — a
// chat-serving replica (Qwen, tool-calling + reasoning
// enabled) and an embedding-serving replica, each its own vLLM
// engine process since one vLLM server hosts exactly one
// model. See k8s/11-vllm.yaml for how those are deployed (GPU
// requests, --served-model-name, probes) and vllmkit.Client's
// doc comment for why the two are separate fields rather than
// one shared host the way a single local Ollama daemon used to
// be.
vllmChatURL = envkit.String("ENKLY_VLLM_CHAT_URL", "http://localhost:8000/v1")
vllmEmbedURL = envkit.String("ENKLY_VLLM_EMBED_URL", "http://localhost:8001/v1")
chatModel = envkit.String("ENKLY_CHAT_MODEL", vllmkit.DefaultChatModel)
embedModel = envkit.String("ENKLY_EMBED_MODEL", vllmkit.DefaultEmbedModel)
// vllmAPIKey is sent as a bearer token on every request to both
// deployments (vLLM's --api-key flag). Required once either
// deployment is reachable from outside the cluster/mesh; leave
// unset for a fully cluster-internal deployment with no auth in
// front of it. See k8s/02-secrets.yaml.
vllmAPIKey = envkit.String("ENKLY_VLLM_API_KEY", "")
// chatThink controls vLLM/Qwen3's extended-thinking toggle
// (vllmkit.Client.Think — "" leaves it to the chat template's
// own default, "true"/"false" forces it, and "low"/"medium"/
// "high" are also accepted for compatibility with pkg/effort's
// vocabulary, though the wire protocol itself has no
// graded-effort equivalent — see Think's doc comment).
// chatMaxTokens/chatTemperature/chatTopP are the per-request
// sampling knobs vLLM actually exposes over HTTP; context
// length and GPU/tensor-parallel layout are NOT request-time
// options under vLLM the way Ollama's num_ctx/num_gpu/
// num_thread were — those are now fixed at server start via
// --max-model-len/--tensor-parallel-size/
// --gpu-memory-utilization on the vLLM deployment itself (see
// k8s/11-vllm.yaml), so there is no client-side equivalent to
// wire up here.
chatThink = envkit.String("ENKLY_CHAT_THINK", "")
chatMaxTokens = envkit.Int("ENKLY_CHAT_MAX_TOKENS", 0)
chatTemperature = envkit.Float("ENKLY_CHAT_TEMPERATURE", 0)
chatTopP = envkit.Float("ENKLY_CHAT_TOP_P", 0)
// Effort control / adaptive thinking (see pkg/effort and
// agentrun.AdaptiveThinkPolicy). defaultEffort seeds every
// Agent's fallback level when a turn carries no /effort
// directive (see turnopts) and no WithEffort override;
// adaptiveThinking turns on content-driven escalation above
// that level's own floor. Both default to this deployment
// behaving exactly as it did before effort control existed
// (effort.Default == Medium, matching the old hardcoded
// MaxRounds=12/DefaultThinkPolicy shape) unless explicitly
// overridden.
defaultEffort = envkit.String("ENKLY_DEFAULT_EFFORT", string(effort.Default))
adaptiveThinking = envkit.Bool("ENKLY_ADAPTIVE_THINKING", true)
// Dynamic workflows (see dispatch_workflow / toolexec/workflow.go).
// workflowMaxParallel is the hard operational ceiling for this
// deployment's vLLM chat capacity; dynamicWorkflowPlans is the
// comma-separated plan allowlist (see pkg/entitlements) —
// "premium" today, see entitlements' package doc comment for why.
workflowMaxParallel = envkit.Int("ENKLY_WORKFLOW_MAX_PARALLEL", 0)
dynamicWorkflowPlans = envkit.String("ENKLY_DYNAMIC_WORKFLOW_PLANS", "")
// Fast mode (see /fast, Service.FastAgent). Unset
// fastChatModel means "fast mode isn't configured on this
// deployment" — StartTurn then just runs a /fast turn on the
// normal Agent, exactly as if fast mode had never been
// requested (see Service.FastAgent's doc comment). A
// deployment that has, say, a smaller/distilled model served
// by its own vLLM replica (or by the same replica under a
// second --served-model-name via vLLM's multi-LoRA/multi-model
// support) points fastChatModel at it to actually unlock the
// latency/cost win fast mode promises; fastChatURL lets that
// replica live on an entirely different host, which is the
// common case (a smaller model usually means a
// smaller/cheaper GPU shape, i.e. a different Deployment).
fastChatModel = envkit.String("ENKLY_FAST_CHAT_MODEL", "")
fastChatURL = envkit.String("ENKLY_FAST_VLLM_CHAT_URL", vllmChatURL)
fastChatThink = envkit.String("ENKLY_FAST_CHAT_THINK", "low")
fastChatMaxTokens = envkit.Int("ENKLY_FAST_CHAT_MAX_TOKENS", 700)
)
clients, err := grpcclients.Dial(workspaceAddr, knowledgeAddr, billingAddr, conversationAddr)
if err != nil {
logger.Error("dial peer services", "err", err)
os.Exit(1)
}
defer clients.Close()
llmClient := vllmkit.New(vllmChatURL, vllmEmbedURL, chatModel, embedModel)
llmClient.APIKey = vllmAPIKey
llmClient.Think = chatThink
llmClient.ChatOptions = vllmkit.Options{
Temperature: chatTemperature, TopP: chatTopP, MaxTokens: chatMaxTokens,
}
// llmClient also backs search_office_skills' semantic index (see
// toolexec.Executor.SkillIndex): the same vLLM-backed client
// already used for chat satisfies skillkit.Embedder as-is.
executor := toolexec.New(clients.Workspace, clients.Knowledge, llmClient)
executor.WorkflowMaxParallel = workflowMaxParallel // 0 means "use toolexec's own default"
// dispatch_subagent (and, built on the same nested Agent,
// dispatch_workflow's parallel steps — see toolexec/workflow.go):
// a nested Agent sharing the same vLLM-backed client but with a
// read-only tool list (toolcatalog.SubagentDefs()) and a
// restricted executor (toolexec.NewRestricted) that enforces that
// list even if the model asks for anything else. Wired as a second
// step, after executor exists, because the restricted executor has
// to wrap executor by pointer — see toolexec.Executor.Subagent's
// doc comment.
executor.Subagent = agentrun.NewSubagent(llmClient, toolexec.NewRestricted(executor), toolcatalog.SubagentDefs())
agent := agentrun.New(llmClient, executor, toolcatalog.Defs())
// Effort control / adaptive thinking: an unrecognized
// ENKLY_DEFAULT_EFFORT value is logged and ignored, leaving New's
// own effort.Default (Medium) in force — a misconfigured env var
// degrading to "the agent's long-standing default behavior" is
// always a safe failure mode, the same stance effort.Level.Resolve
// itself takes for an invalid value reaching it at runtime.
if lvl, err := effort.Parse(defaultEffort); err != nil {
logger.Warn("invalid ENKLY_DEFAULT_EFFORT, using default", "value", defaultEffort, "err", err)
} else {
agent.Effort = lvl
}
if adaptiveThinking {
agent.AdaptiveThink = agentrun.AdaptiveThinkPolicy
}
// ENKLY.md project memory: if the workspace root has one, its
// content is folded into the system prompt on every turn (see
// agentrun.Agent.buildSystemPrompt) — the same idea as Claude Code
// reading a CLAUDE.md. A missing file or an unreachable workspace
// service both just mean "no project memory today"; this must never
// fail a turn over it, so the error is swallowed here, not returned.
const projectMemoryPath = "ENKLY.md"
agent.MemoryLoader = func(ctx context.Context) string {
resp, err := clients.Workspace.ReadFile(ctx, &workspacev1.ReadFileRequest{Path: projectMemoryPath})
if err != nil || resp.GetIsError() {
return ""
}
return resp.GetContent()
}
// Fast mode: only wired up when ENKLY_FAST_CHAT_MODEL names a
// distinct served model — see fastChatModel's doc comment above and
// application.Service.FastAgent's. fastAgent shares executor (and
// therefore the same dispatch_subagent/dispatch_workflow nested
// Agent) with the normal agent; only the ChatStreamer differs.
var fastAgent *agentrun.Agent
if fastChatModel != "" {
fastClient := vllmkit.New(fastChatURL, vllmEmbedURL, fastChatModel, embedModel)
fastClient.APIKey = vllmAPIKey
fastClient.Think = fastChatThink
fastClient.ChatOptions = vllmkit.Options{MaxTokens: fastChatMaxTokens}
fastAgent = agentrun.New(fastClient, executor, toolcatalog.Defs())
fastAgent.MemoryLoader = agent.MemoryLoader
fastAgent.Effort = effort.Low
logger.Info("fast mode configured", "fast_chat_model", fastChatModel, "fast_chat_url", fastChatURL)
}
svc := &application.Service{
Streams: domain.NewManager(),
Agent: agent,
FastAgent: fastAgent,
Billing: clients.Billing,
Conv: clients.Conversation,
Logger: logger,
// Same window/backoff as the monolith's ChatLimiter — a gentle
// per-account throttle that "only bites a tight scripted loop".
ChatLimiter: authsupport.NewLimiter(time.Minute, 200*time.Millisecond, 4*time.Second),
Entitlements: entitlements.ParsePlanList(dynamicWorkflowPlans),
}
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
grpcServer := grpc.NewServer(grpc.MaxRecvMsgSize(24<<20), grpc.MaxSendMsgSize(24<<20))
agentv1.RegisterAgentServiceServer(grpcServer, grpcsrv.New(svc))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("agent service listening", "addr", grpcAddr, "chat_model", chatModel)
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,457 @@
// Package application holds the agent service's use cases — the
// transplant of internal/handlers/chat.go's Handlers.Chat (minus HTTP
// parsing/rendering) plus its runAgent background goroutine. This is a
// saga coordinator: every use case here calls out over gRPC to
// billing, conversation, workspace (via toolexec) and knowledge rather
// than touching a database of its own.
package application
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
"enkly/pkg/authsupport"
"enkly/pkg/entitlements"
"enkly/pkg/i18n"
"enkly/pkg/turnopts"
"enkly/pkg/vllmkit"
"enkly/services/agent/internal/domain"
"enkly/services/agent/internal/infrastructure/agentrun"
"enkly/services/agent/internal/infrastructure/toolexec"
)
const (
maxPromptChars = 32_000
historyMessages = 60 // most recent user/assistant messages replayed to the model
// defaultConversationTitle mirrors the conversation service's own
// DefaultConversationTitle — duplicated as a literal rather than
// imported, since depending on another service's internal package
// would violate the service boundary; see conversation's
// migrations/0001_init.sql column default, which is the actual
// source of truth both sides must agree with.
defaultConversationTitle = "New conversation"
maxRunDuration = 30 * time.Minute
sseKeepAliveInterval = 15 * time.Second
sseCoalesceDelay = 30 * time.Millisecond
)
// Outcome mirrors agentv1.StartTurnOutcome without importing the
// generated package into this layer — see internal/interfaces/grpc for
// the translation.
type Outcome int
const (
OutcomeOK Outcome = iota
OutcomeConversationNotFound
OutcomeConversationBusy
OutcomeTooManyStreams
OutcomeRateLimited
OutcomeQuotaExceeded
OutcomeInternalError
)
type StartTurnResult struct {
Outcome Outcome
Message string
StreamID string
}
type Service struct {
Streams *domain.Manager
Agent *agentrun.Agent
Billing billingv1.BillingServiceClient
Conv conversationv1.ConversationServiceClient
Logger *slog.Logger
// ChatLimiter replaces Handlers.ChatLimiter: a gentle per-account
// throttle that "only bites a tight scripted loop".
ChatLimiter *authsupport.Limiter
// FastAgent, when non-nil, is used instead of Agent for a turn that
// requests fast mode (see turnopts' /fast directive) — typically
// wired in main.go to an *agentrun.Agent sharing Executor but built
// around a second, smaller/quantized *vllmkit.Client configured
// for lower latency and cost per the "2.5x faster... 3x cheaper"
// fast-mode brief. A deployment that hasn't configured a distinct
// fast model leaves this nil, and a /fast request just runs on
// Agent like any other turn — fast mode degrading to "no change"
// rather than erroring is deliberate, since it's meant to be
// available on every plan with zero configuration required.
FastAgent *agentrun.Agent
// Entitlements resolves which plans unlock dynamic workflows (see
// pkg/entitlements) — built once in main.go from
// ENKLY_DYNAMIC_WORKFLOW_PLANS and consulted on every StartTurn
// from the plan name billing's own CheckAndIncrementUsage call
// already returns, so this needs no gRPC call of its own. The zero
// Catalog (an unconfigured Service, e.g. in a test) resolves every
// plan as not entitled — see entitlements.Catalog.Resolve.
Entitlements entitlements.Catalog
}
func cleanTitle(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return strings.TrimSpace(string(runes[:max-1])) + "…"
}
// titleFromPrompt replaces handlers.titleFromPrompt.
func titleFromPrompt(prompt string) string {
first := prompt
if i := strings.IndexByte(prompt, '\n'); i >= 0 {
first = prompt[:i]
}
if t := cleanTitle(first, 60); t != "" {
return t
}
return defaultConversationTitle
}
// buildHistory replaces handlers.buildHistory: earlier turns' raw tool
// results are deliberately left out — their assistant tool-call
// messages aren't persisted, so replaying the results would present
// the model with orphaned tool output; the assistant's own summary of
// what it found is already in the text.
func buildHistory(msgs []*conversationv1.Message) []vllmkit.Message {
out := make([]vllmkit.Message, 0, len(msgs))
for _, m := range msgs {
role := fromProtoRole(m.GetRole())
if (role == vllmkit.RoleUser || role == vllmkit.RoleAssistant) && strings.TrimSpace(m.GetContent()) != "" {
out = append(out, vllmkit.Message{Role: role, Content: m.GetContent()})
}
}
if len(out) > historyMessages {
out = out[len(out)-historyMessages:]
}
return out
}
func fromProtoRole(r conversationv1.Role) vllmkit.Role {
switch r {
case conversationv1.Role_ROLE_USER:
return vllmkit.RoleUser
case conversationv1.Role_ROLE_ASSISTANT:
return vllmkit.RoleAssistant
case conversationv1.Role_ROLE_TOOL:
return vllmkit.RoleTool
default:
return vllmkit.RoleSystem
}
}
func toProtoRole(r vllmkit.Role) conversationv1.Role {
switch r {
case vllmkit.RoleUser:
return conversationv1.Role_ROLE_USER
case vllmkit.RoleAssistant:
return conversationv1.Role_ROLE_ASSISTANT
case vllmkit.RoleTool:
return conversationv1.Role_ROLE_TOOL
default:
return conversationv1.Role_ROLE_SYSTEM
}
}
// StartTurn replaces Handlers.Chat's synchronous prefix, launching the
// same work Handlers.runAgent did in a background goroutine.
//
// lang is the visitor's resolved UI locale (see the gateway's
// localeFrom / langFromIncomingContext in interfaces/grpc/server.go)
// — every message StartTurn itself returns is looked up in that
// locale's catalog (see pkg/i18n), and it's also threaded onto runCtx
// via agentrun.WithPreferredLanguage so the model's system prompt
// carries it as an explicit, standing language preference alongside
// its own per-message detection (see agentrun's "## Language"
// section).
func (s *Service) StartTurn(ctx context.Context, userID, convID int64, prompt string, lang i18n.Locale) StartTurnResult {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.type_message_first")}
}
if len([]rune(prompt)) > maxPromptChars {
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.message_too_long", maxPromptChars)}
}
// Leading /effort, /fast and /system directive lines (see
// turnopts' doc comment for exactly why this, rather than new
// proto fields, is today's working surface for those three
// features) are parsed off and stripped before the prompt is
// persisted or sent to the model — a directive is a control signal
// for this service, not part of the conversation the model or a
// human reviewing history needs to see. prompt itself is left
// untouched if there were no directives, so the overwhelming
// majority of turns pay no cost here.
opts, prompt := turnopts.Parse(prompt)
if opts.HadDirectives {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.type_message_first")}
}
}
if !throttle(ctx, s.ChatLimiter, "chat:"+strconv.FormatInt(userID, 10)) {
return StartTurnResult{Outcome: OutcomeRateLimited, Message: i18n.T(lang, "chat.rate_limited")}
}
// Plan-based daily message cap: a lookup failure fails open (logged,
// not blocking), matching the monolith's stance that a transient
// billing hiccup shouldn't lock someone out of chat. planName stays
// "" on a failed lookup, which entitlements.Catalog.Resolve treats
// as "not entitled" for dynamic workflows — the opposite failure
// mode from the quota check just above, and deliberately so: see
// entitlements.Catalog.Resolve's doc comment on failing closed for
// a gated paid feature versus failing open for message quota.
var planName string
if usage, err := s.Billing.CheckAndIncrementUsage(ctx, &billingv1.CheckAndIncrementUsageRequest{UserId: userID}); err != nil {
s.Logger.Warn("check usage", "err", err, "user_id", userID)
} else if !usage.GetAllowed() {
return StartTurnResult{Outcome: OutcomeQuotaExceeded, Message: i18n.T(lang, "billing.quota_exceeded", usage.GetPlanName(), usage.GetLimit())}
} else {
planName = usage.GetPlanName()
}
conv, err := s.Conv.GetConversation(ctx, &conversationv1.GetConversationRequest{UserId: userID, ConversationId: convID})
if err != nil {
return StartTurnResult{Outcome: OutcomeConversationNotFound, Message: i18n.T(lang, "chat.conversation_gone")}
}
runCtx, cancel := context.WithTimeout(context.Background(), maxRunDuration)
runCtx = agentrun.WithPreferredLanguage(runCtx, lang)
if opts.Effort.Valid() {
runCtx = agentrun.WithEffort(runCtx, opts.Effort)
}
if len(opts.SystemEntries) > 0 {
runCtx = agentrun.WithSystemEntries(runCtx, toAgentrunSystemEntries(opts.SystemEntries))
}
runCtx = toolexec.WithEntitlements(runCtx, s.Entitlements.Resolve(planName))
// Fast mode (see /fast and Service.FastAgent's doc comment) picks
// which Agent actually runs this turn — everything else about the
// run (context, callbacks, persistence) is identical either way.
runAgent := s.Agent
if opts.Fast && s.FastAgent != nil {
runAgent = s.FastAgent
}
stream, err := s.Streams.Create(userID, conv.GetId(), cancel)
if err != nil {
cancel()
switch {
case errors.Is(err, domain.ErrConversationBusy):
return StartTurnResult{Outcome: OutcomeConversationBusy, Message: i18n.T(lang, "chat.conversation_busy")}
case errors.Is(err, domain.ErrTooManyStreams):
return StartTurnResult{Outcome: OutcomeTooManyStreams, Message: i18n.T(lang, "chat.too_many_streams")}
default:
s.Logger.Error("create stream", "err", err)
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "errors.generic")}
}
}
if _, err := s.Conv.AddMessage(ctx, &conversationv1.AddMessageRequest{
ConversationId: conv.GetId(), Role: conversationv1.Role_ROLE_USER, Content: prompt,
}); err != nil {
cancel()
stream.Finish("")
s.Streams.ScheduleRemoval(stream)
s.Logger.Error("save user message", "err", err)
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.save_failed")}
}
if _, err := s.Conv.AutoTitleConversation(ctx, &conversationv1.AutoTitleConversationRequest{
UserId: userID, ConversationId: conv.GetId(), Title: titleFromPrompt(prompt),
}); err != nil {
s.Logger.Warn("auto-title conversation", "err", err)
}
msgsResp, err := s.Conv.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: conv.GetId()})
if err != nil {
cancel()
stream.Finish("")
s.Streams.ScheduleRemoval(stream)
s.Logger.Error("load history", "err", err)
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.load_failed")}
}
go s.runTurn(runCtx, cancel, stream, conv.GetId(), buildHistory(msgsResp.GetMessages()), runAgent)
return StartTurnResult{Outcome: OutcomeOK, StreamID: stream.ID}
}
// toAgentrunSystemEntries translates turnopts.SystemEntry (a
// transport-agnostic shape pkg/turnopts owns so it doesn't need to
// import the agent service's internal agentrun package) to
// agentrun.SystemEntry — the one call site that actually needs both
// types in scope.
func toAgentrunSystemEntries(in []turnopts.SystemEntry) []agentrun.SystemEntry {
out := make([]agentrun.SystemEntry, 0, len(in))
for _, e := range in {
var kind agentrun.SystemEntryKind
switch e.Kind {
case turnopts.SystemEntryPermission:
kind = agentrun.SystemEntryPermission
case turnopts.SystemEntryBudget:
kind = agentrun.SystemEntryBudget
default:
kind = agentrun.SystemEntryInstruction
}
out = append(out, agentrun.SystemEntry{Kind: kind, Content: e.Content})
}
return out
}
// recordedCall mirrors handlers.recordedCall — used to match a
// persisted tool message back to its structured call/result for the
// tool_calls audit log, since callbacks can fire out of order when
// tools run concurrently.
type recordedCall struct {
call vllmkit.ToolCallRequest
result vllmkit.ToolResult
used bool
}
// runTurn replaces Handlers.runAgent. runAgent is the *agentrun.Agent
// StartTurn selected for this turn — s.Agent normally, or s.FastAgent
// when fast mode was requested and configured (see StartTurn) — rather
// than a hardcoded s.Agent, so fast mode can swap in a differently
// configured Agent with zero other change to how a turn runs.
func (s *Service) runTurn(ctx context.Context, cancel context.CancelFunc, stream *domain.Stream, convID int64, history []vllmkit.Message, runAgent *agentrun.Agent) {
defer cancel()
defer s.Streams.ScheduleRemoval(stream)
defer func() {
if rec := recover(); rec != nil {
s.Logger.Error("panic in agent run", "panic", rec, "conversation_id", convID)
stream.Finish("⚠️ Something went wrong while running the agent.")
}
}()
var (
mu sync.Mutex
calls []*recordedCall
)
cb := agentrun.Callbacks{
OnToken: stream.SetToken,
OnThinking: stream.SetThinking,
OnThinkingRound: stream.AddThinkingRound,
OnToolCall: func(call vllmkit.ToolCallRequest, result vllmkit.ToolResult) {
mu.Lock()
calls = append(calls, &recordedCall{call: call, result: result})
mu.Unlock()
stream.AddTool(domain.ToolCallResult{
Name: result.Name, ArgumentsJSON: argsJSON(call.Arguments), Result: result.Content, IsError: result.IsError,
})
},
}
final, toPersist, runErr := runAgent.Run(ctx, history, cb)
pctx, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
defer pcancel()
note := ""
switch {
case runErr == nil:
case agentrun.IsCancelled(runErr) && errors.Is(ctx.Err(), context.DeadlineExceeded):
note = fmt.Sprintf("⏱ Stopped: this reply hit the %s time limit.", maxRunDuration)
case agentrun.IsCancelled(runErr):
note = "⏹ Stopped."
case vllmkit.IsUpstreamUnavailable(runErr):
// The vLLM chat deployment is unreachable (Pending/restarting/
// mid-rollout) — a raw "dial tcp ...: connection refused"
// tells the end user nothing actionable, so show a short
// status message instead and keep the real error in the logs
// for whoever runs k8s/scripts/gpu-preflight.sh.
s.Logger.Error("agent run failed: vllm upstream unavailable", "err", runErr, "conversation_id", convID)
note = "⚠️ The AI model service is starting up or temporarily unavailable. Please try again in a minute."
default:
s.Logger.Error("agent run failed", "err", runErr, "conversation_id", convID)
note = "⚠️ " + runErr.Error()
}
if note != "" {
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: note})
}
for _, m := range toPersist {
resp, err := s.Conv.AddMessage(pctx, &conversationv1.AddMessageRequest{
ConversationId: convID, Role: toProtoRole(m.Role), Content: m.Content, ToolName: m.ToolName,
})
if err != nil {
s.Logger.Error("persist message", "err", err, "conversation_id", convID)
continue
}
if m.Role != vllmkit.RoleTool {
continue
}
mu.Lock()
for _, rc := range calls {
if !rc.used && rc.call.Name == m.ToolName && rc.result.Content == m.Content {
rc.used = true
if _, err := s.Conv.AddToolCall(pctx, &conversationv1.AddToolCallRequest{
MessageId: resp.GetId(), ToolName: rc.call.Name, ArgumentsJson: argsJSON(rc.call.Arguments),
Result: rc.result.Content, IsError: rc.result.IsError,
}); err != nil {
s.Logger.Warn("persist tool call", "err", err)
}
break
}
}
mu.Unlock()
}
display := strings.TrimSpace(final)
if note != "" {
if display != "" {
display += "\n\n"
}
display += note
}
if display == "" {
display = "*The model returned an empty response.*"
}
var toolCalls []domain.ToolCallResult
mu.Lock()
for _, rc := range calls {
toolCalls = append(toolCalls, domain.ToolCallResult{
Name: rc.call.Name, ArgumentsJSON: argsJSON(rc.call.Arguments), Result: rc.result.Content, IsError: rc.result.IsError,
})
}
mu.Unlock()
stream.FinishWithToolCalls(display, toolCalls)
}
// StopTurn replaces Handlers.StopStream. Returns false if streamID
// doesn't belong to userID (or doesn't exist), exactly like the
// monolith's http.NotFound check.
func (s *Service) StopTurn(userID int64, streamID string) bool {
stream := s.Streams.Get(streamID)
if stream == nil || stream.UserID != userID {
return false
}
stream.Cancel()
return true
}
func argsJSON(args map[string]any) string {
// Matches llm.ArgsJSON's fallback-to-"{}" behavior exactly.
b, err := json.Marshal(args)
if err != nil {
return "{}"
}
return string(b)
}

View File

@@ -0,0 +1,42 @@
package application
import (
"context"
"time"
"enkly/pkg/authsupport"
)
// throttle replaces Handlers.throttle (see the same helper in the iam
// service's application package for the full doc comment — identical
// behavior, duplicated rather than shared because it's five lines and
// not worth a dependency between two bounded contexts).
func throttle(ctx context.Context, l *authsupport.Limiter, keys ...string) bool {
var wait time.Duration
for _, k := range keys {
if d := l.Delay(k); d > wait {
wait = d
}
}
if !sleepCtx(ctx, wait) {
return false
}
for _, k := range keys {
l.RecordFailure(k)
}
return true
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return ctx.Err() == nil
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}

View File

@@ -0,0 +1,203 @@
// Package domain holds the agent service's own aggregate: one in-flight
// (or just-finished) agent run. Transplanted from
// internal/handlers/chat.go's StreamManager/Stream, with one
// deliberate change: where the monolith's Stream stored pre-rendered
// HTML (because the same process also owned templates), this one
// stores plain text and structured tool calls — rendering now happens
// in the gateway, the only service that imports pkg/renderkit. See
// ARCHITECTURE.md's "Where rendering lives" note.
package domain
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
)
const (
MaxRunDuration = 30 * time.Minute // hard cap on one agent turn
MaxStreamsPerUser = 3 // concurrent in-flight replies per account
FinishedStreamTTL = 10 * time.Minute // how long a finished stream stays replayable
)
var (
ErrConversationBusy = errors.New("this conversation is still answering")
ErrTooManyStreams = errors.New("too many replies in progress")
)
// ToolCallResult is one completed tool call, as recorded on a Stream.
type ToolCallResult struct {
Name string
ArgumentsJSON string
Result string
IsError bool
}
// ThinkingRound is one agent round's own standalone reasoning trace, as
// recorded on a Stream — the round-indexed counterpart to ThinkText's
// single running total. See agentrun.Callbacks.OnThinkingRound.
type ThinkingRound struct {
Round int
Thinking string
}
// Stream is the observable state of one agent run. Like the monolith's
// version, it keeps the latest snapshot rather than a log of every
// token event (token text is cumulative anyway), plus an append-only
// list of completed tool calls, keeping memory bounded and letting a
// late attacher (StreamTurn reconnecting) cheaply catch up.
type Stream struct {
ID string
UserID int64
ConvID int64
cancel context.CancelFunc
mu sync.Mutex
changed chan struct{} // closed & replaced on every update (broadcast)
tokenText string // latest cumulative reply
thinkText string // latest cumulative reasoning trace
tools []ToolCallResult
thinkingRounds []ThinkingRound // append-only, one per round that produced reasoning — see AddThinkingRound
finalText string
// finalToolCalls is every tool call made during the run (successful
// or not), handed to the gateway alongside finalText so it can
// build deliverable cards the same way
// internal/handlers/deliverables.go did — see TurnDone in
// api/proto/agent/v1/agent.proto.
finalToolCalls []ToolCallResult
done bool
}
type StreamSnapshot struct {
TokenText, ThinkText, FinalText string
Tools []ToolCallResult
ThinkingRounds []ThinkingRound
FinalToolCalls []ToolCallResult
Done bool
}
func (s *Stream) update(fn func()) {
s.mu.Lock()
fn()
close(s.changed)
s.changed = make(chan struct{})
s.mu.Unlock()
}
func (s *Stream) Snapshot() (StreamSnapshot, <-chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
return StreamSnapshot{
TokenText: s.tokenText, ThinkText: s.thinkText, FinalText: s.finalText,
Tools: s.tools[:len(s.tools):len(s.tools)], // append-only; safe to share
ThinkingRounds: s.thinkingRounds[:len(s.thinkingRounds):len(s.thinkingRounds)], // append-only; safe to share
FinalToolCalls: s.finalToolCalls,
Done: s.done,
}, s.changed
}
func (s *Stream) IsDone() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.done
}
func (s *Stream) SetToken(text string) { s.update(func() { s.tokenText = text }) }
func (s *Stream) SetThinking(text string) { s.update(func() { s.thinkText = text }) }
func (s *Stream) AddTool(tc ToolCallResult) { s.update(func() { s.tools = append(s.tools, tc) }) }
// AddThinkingRound records one round's standalone reasoning trace —
// the round-indexed counterpart to SetThinking's running total. See
// agentrun.Callbacks.OnThinkingRound.
func (s *Stream) AddThinkingRound(round int, thinking string) {
s.update(func() { s.thinkingRounds = append(s.thinkingRounds, ThinkingRound{Round: round, Thinking: thinking}) })
}
func (s *Stream) Finish(finalText string) {
s.update(func() { s.finalText = finalText; s.done = true })
}
// FinishWithToolCalls is Finish plus the full tool-call list for this
// run, used by the normal (non-panic, non-empty-history) completion
// path in runTurn.
func (s *Stream) FinishWithToolCalls(finalText string, toolCalls []ToolCallResult) {
s.update(func() { s.finalText = finalText; s.finalToolCalls = toolCalls; s.done = true })
}
func (s *Stream) Cancel() { s.cancel() }
// Manager tracks in-flight (and just-finished) agent runs — identical
// in behavior to the monolith's StreamManager, including the "second
// concurrent run on the same conversation is refused, not queued"
// invariant (their messages would interleave) and the per-user
// concurrency cap.
type Manager struct {
mu sync.Mutex
streams map[string]*Stream
byConv map[int64]*Stream
}
func NewManager() *Manager {
return &Manager{streams: map[string]*Stream{}, byConv: map[int64]*Stream{}}
}
func (m *Manager) Create(userID, convID int64, cancel context.CancelFunc) (*Stream, error) {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.byConv[convID]; ok && !s.IsDone() {
return nil, ErrConversationBusy
}
active := 0
for _, s := range m.streams {
if s.UserID == userID && !s.IsDone() {
active++
}
}
if active >= MaxStreamsPerUser {
return nil, ErrTooManyStreams
}
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return nil, err
}
s := &Stream{ID: hex.EncodeToString(b[:]), UserID: userID, ConvID: convID, cancel: cancel, changed: make(chan struct{})}
m.streams[s.ID] = s
m.byConv[convID] = s
return s, nil
}
func (m *Manager) Get(id string) *Stream {
m.mu.Lock()
defer m.mu.Unlock()
return m.streams[id]
}
func (m *Manager) ActiveFor(convID int64) *Stream {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.byConv[convID]; ok && !s.IsDone() {
return s
}
return nil
}
func (m *Manager) CancelConversation(convID int64) {
if s := m.ActiveFor(convID); s != nil {
s.Cancel()
}
}
func (m *Manager) ScheduleRemoval(s *Stream) {
time.AfterFunc(FinishedStreamTTL, func() {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.streams, s.ID)
if m.byConv[s.ConvID] == s {
delete(m.byConv, s.ConvID)
}
})
}

View File

@@ -0,0 +1,924 @@
// Package agentrun is the agent service's core: the same round-trip
// loop as the monolith's internal/llm/agent.go (send history to the
// chat model, execute any requested tool calls, feed results back, repeat
// until a final answer), with two changes forced by the split into
// services: tool execution goes through a ToolExecutor (an interface
// satisfied by toolexec.Executor, which calls out to the workspace and
// knowledge services over gRPC) instead of an in-process Toolbox, and
// the SystemPrompt's tool descriptions now live in
// internal/infrastructure/toolcatalog rather than being read directly
// off the Toolbox. The round-trip algorithm itself — compactToolHistory,
// MaxRounds, the concurrent-tool-call fan-out — is unchanged.
package agentrun
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"enkly/pkg/effort"
"enkly/pkg/i18n"
"enkly/pkg/vllmkit"
)
// SystemPrompt is prepended to every main-agent conversation. It is not
// persisted; Agent rebuilds it fresh on every turn (plus whatever
// MemoryLoader contributes — see buildSystemPrompt) — identical text
// to the monolith's, since the model has been prompted and tested
// against this precise wording.
const SystemPrompt = `You are enkly, a local, open-source agentic coding assistant modeled after Claude Code.
## Tools
- list_directory, glob_search — orient yourself: see what's there before guessing paths.
- grep — exact/lexical search (a symbol, an error string, a TODO). Fast and precise.
- search_codebase — fuzzy, conceptual search ("where do we validate a session") over the
pgvector-indexed codebase. Prefer grep when you already know the literal text you want.
- read_file — always read a file (or the specific lines you need, via offset/limit on a
large one) before editing it. Never edit blind.
- edit_file — the default way to change an EXISTING file: give an exact old_string (with
enough surrounding context to be unique) and its replacement. It fails loudly instead of
guessing if that text isn't found or isn't unique, so a failure means "go read the file
again," not "try something else and hope." Returns a diff preview — read it to confirm
the change landed where you meant.
- multi_edit_file — several edit_file-style replacements applied to ONE file as a single
atomic operation: if any edit in the list fails, none of them are written. Prefer this
over multiple separate edit_file calls on the same file (e.g. renaming a symbol at its
definition and every call site) — one round-trip, and it can never leave the file
half-edited if a later edit in the batch turns out to be wrong. Edits apply in order, so
a later one may rely on text an earlier one just introduced.
- write_file — only for brand-new files, or a genuine full rewrite you've deliberately
chosen over a series of edits. Never use it to make a small change to an existing file;
that risks silently dropping unrelated content.
- run_bash_command — builds, tests, formatters, linters, git. After any nontrivial edit,
prefer running the relevant build/tests here over asserting the change is correct.
- write_todos — for a nontrivial request (roughly: more than ~3 steps, or touching more
than one file), write a short plan as todos before touching anything, mark exactly one
item in_progress at a time as you work through it, and mark it completed before moving
on. Skip this entirely for a small, single-step request — planning overhead a user
didn't need to see is worse than no plan.
- solve_ml_math — for any concrete, numeric ML-math question (linear algebra, a
derivative/gradient/integral, a probability or statistics computation, gradient descent
or root finding, an activation or loss function, fitting a least-squares linear
regression), compute it with this tool instead of doing the arithmetic yourself in
prose. It runs real Go numerical code, so it won't silently slip a digit the way working
it out by hand can. Explain the result in plain terms after the call; don't just paste
the raw tool output.
- read_office_skill — optional background reading for the document tools below, not a
required first step: create_document/create_spreadsheet/create_presentation's own
descriptions already cover what you need for an ordinary document (a CV, a memo, a
one-sheet table), so for one of those, call the create_*/edit_* tool directly — don't
spend a round reading a skill file first. Reach for this before something that leans on
a format's sharper edges (an Excel formula or multi-sheet workbook, a multi-slide
PowerPoint deck, edit_document's replace_text run-splitting caveat) or when you're not
sure a feature someone's asking for is even supported. Start with the "overview" skill
if you're not sure which format a request needs. No need to re-read a skill you've
already read earlier in this same conversation.
- search_office_skills — a retrieval-augmented complement to read_office_skill: give it a
specific question ("can a cell hold a formula", "how do I bold part of a paragraph")
and it returns just the matching section(s) across the office skills, ranked by semantic
similarity, instead of a whole skill file. Reach for this when you have one narrow
question mid-task rather than re-reading a full skill; reach for read_office_skill when
you want a format's complete guidance before starting.
- create_document/read_document/edit_document, create_spreadsheet/read_spreadsheet/
edit_spreadsheet, create_presentation/read_presentation/edit_presentation — Word, Excel
and PowerPoint document management. Give plain structured fields (paragraph text and
style, a grid of cell values, a slide's title and bullets) — never write raw OOXML/XML
yourself; these tools render a real, valid .docx/.xlsx/.pptx file from what you give
them. Read a document/spreadsheet/presentation back before editing it if you're not sure
what it currently contains.
- dispatch_subagent — hand off a broad or open-ended investigation ("find every place X
happens", "map out how Y flows through this service") to a read-only sub-agent that
explores on its own and returns one written report, instead of chaining many of your own
search/read calls. Its exploration never touches your context — only its final report
does — so this is how you keep a long investigation from eating the context budget you
need for the actual edit. It cannot write files or run commands; dispatch work to it, not
changes. For a single targeted lookup, just call the tool yourself — dispatching has
overhead of its own.
- dispatch_workflow — the large-scale version of dispatch_subagent: give it a batch of
independent (or dependency-ordered) steps and it runs them in parallel, verifies each
report against its own step, and hands you back a per-step status so you can see at a
glance what needs attention. Reach for this on a job that's really the same investigation
repeated across many files, services, or call sites (a migration, a repo-wide rename, "map
what every handler in this package does") instead of looping dispatch_subagent calls
yourself one at a time — that serializes work this tool runs concurrently. It carries the
same read-only boundary dispatch_subagent has (investigate and report, never write or run),
so plan the actual edits as a follow-up once you've read every step's findings. Not
available on every plan; if it reports itself unavailable, fall back to dispatch_subagent.
## How to work
- If the workspace root has an ENKLY.md file, its contents appear below under "Project
memory" — read it like a project README written specifically for you (conventions,
gotchas, how to run things here) and weigh it alongside, not instead of, what you find by
actually looking at the code; it can go stale.
- Investigate before acting: for anything beyond a one-line, obvious fix, look at the
relevant code (list_directory/glob_search/grep/search_codebase, then read_file) before
proposing or making a change, rather than pattern-matching from the file names alone.
- Independent reads (several files, a search plus a directory listing) can be requested in
the same round — they'll run concurrently — but don't parallelize a write with anything
that depends on reading first.
- Prefer several small, verifiable edits over one large speculative rewrite. Verify with
run_bash_command (build/tests) when you can, rather than only asserting the change works.
- State your plan briefly before calling tools, and summarize what actually happened
afterward — including when a tool failed and what you did about it. Don't narrate every
intermediate tool call at length; keep the reasoning proportional to the task.
- If a tool call fails, read the error, adjust, and retry rather than repeating the same
call or silently giving up. If you're genuinely blocked (missing credentials, an
ambiguous requirement), say so plainly instead of guessing.
- Decide with a tool call, don't describe one: if a request needs a file read, a search run,
or a change made, call the matching tool in this same turn rather than writing "I'll read
X" or "let's check Y" as plain text and stopping — that line of prose isn't the action, and
leaving it unpaired with the actual call wastes a round. Conversely, don't call a tool out
of habit when you already have what you need (e.g. re-reading a file you read two rounds
ago and haven't touched since) — pick the smallest set of calls that actually moves the
task forward, in parallel when they're independent of each other (see above).
- Say what you actually found, including the parts that don't look good: if a change you
made doesn't fully address the request, if tests fail and you couldn't get them passing, if
you noticed a bug or a risk while working that's outside what was asked, or if you're not
sure an edit is correct, say so plainly in your summary rather than describing the work as
more complete or more certain than it is. A clearly flagged limitation is useful; a clean-
sounding summary that quietly omits a known problem is not — the user can only act on what
you tell them, so an unreported bug you noticed is strictly worse than a reported one you
didn't have time to fix.
## Language
- Detect the language the user is writing in and reply in that same language by default —
don't ask them to pick one, and don't switch to English just because the codebase or your
tools are in English. This covers everything you say to them: prose, plans, write_todos
items, and summaries of what you changed.
- If they switch languages mid-conversation, follow the switch on your next reply. If a
message mixes languages (e.g. a pasted English stack trace inside an otherwise Spanish
question), reply in the language of their own prose and leave quoted code, paths,
commands, and error text exactly as given — don't translate those.
- Code stays in English regardless of conversation language: identifiers, source comments,
commit messages, and code-review feedback written inline in a diff follow the codebase's
existing convention. Only your own explanations to the user switch languages.
- If the user explicitly asks for replies in a specific language, use it from then on even
if their own messages continue in a different one.`
// preferredLanguageKey is the context.Context key WithPreferredLanguage
// stores a locale under. An unexported type (not string) so no other
// package's context.WithValue key can collide with it — the standard
// Go idiom for a context key.
type preferredLanguageKey struct{}
// WithPreferredLanguage attaches the visitor's resolved UI locale to
// ctx, for Run to fold into the system prompt (see buildSystemPrompt's
// languageDirective). This is a second, standing signal layered on
// top of the model's own per-message language detection (the
// "## Language" section of SystemPrompt above) rather than a
// replacement for it:
//
// - The caller (application.Service.StartTurn) always has SOME
// locale by this point — an explicit cookie from the language
// switcher, or failing that enkly's best guess from the visitor's
// browser Accept-Language header, or failing that i18n.Default
// ("en") — see the gateway's localeFrom and grpc/server.go's
// langFromIncomingContext, which is where that resolution actually
// happens; nothing in this package re-derives it. So this directive
// is present on effectively every run, not only for a visitor who
// has explicitly touched the language switcher — which is exactly
// why languageDirective's wording below says "is using", not "has
// set": it's honest about covering both an explicit choice and a
// negotiated guess.
// - What it's FOR is a reply to a short or ambiguous message (a bare
// greeting, or any message before the visitor has typed enough for
// detection alone to confidently place) — the same role Claude
// Code's own `/language` setting plays alongside its automatic
// detection, just seeded from a browser header when no explicit
// choice exists yet rather than left unset.
// - SystemPrompt's own rule still wins for the actual conversation:
// if the visitor starts typing in a language that disagrees with
// this directive, follow what they're typing (see "If they switch
// languages mid-conversation…" above) — this is a default for when
// there's nothing stronger to go on, never an override of what's
// in front of the model right now.
//
// context.Value plumbing (rather than a field on Agent or a Run
// parameter) was the natural fit here because MemoryLoader already
// establishes the same pattern one field up: a per-run signal threaded
// through ctx and read back inside buildSystemPrompt, not stored on
// the shared, concurrently-used Agent struct. dispatch_subagent passes
// its own derived context straight through to the sub-agent's Run
// (see toolexec.Executor.dispatchSubagent), so a sub-agent's report
// lands in the same language as the conversation that spawned it with
// no extra plumbing needed.
func WithPreferredLanguage(ctx context.Context, lang i18n.Locale) context.Context {
return context.WithValue(ctx, preferredLanguageKey{}, lang)
}
// preferredLanguageFrom reads back what WithPreferredLanguage stored,
// or "" if nothing was ever set (no gateway call set it, a direct
// internal call to Run, or a test).
func preferredLanguageFrom(ctx context.Context) i18n.Locale {
lang, _ := ctx.Value(preferredLanguageKey{}).(i18n.Locale)
return lang
}
// languageDirective turns a non-empty preferred locale into the extra
// system-prompt paragraph buildSystemPrompt appends. Kept separate
// from buildSystemPrompt itself mainly so the exact wording — and the
// reasoning behind it — has one obvious place to live and to test.
//
// "is using", deliberately not "has set": per WithPreferredLanguage's
// doc comment, lang is almost always present and may be an explicit
// choice from enkly's language switcher or just a browser-negotiated
// guess — the model shouldn't be told a visitor "set" something they
// may never have touched.
func languageDirective(lang i18n.Locale) string {
if lang == "" {
return ""
}
return fmt.Sprintf(
"\n\n## Interface language\nThe user is using %s as enkly's interface language. "+
"Treat that as your default reply language for this conversation — including a first reply to a "+
"short greeting or an ambiguous message you can't otherwise place. The \"## Language\" rules above "+
"still apply on top of this: if the user is actually typing in a different language, follow what "+
"they're typing, not this setting.",
i18n.NameOf(lang),
)
}
// SubagentSystemPrompt is prepended instead of SystemPrompt for a run
// launched by the main agent's dispatch_subagent tool (see
// NewSubagent). The sub-agent only ever gets one user message — the
// "task" string the main agent wrote — and no ongoing conversation, so
// this prompt is written around a single investigate-then-report turn
// rather than an open-ended back-and-forth.
const SubagentSystemPrompt = `You are a focused, read-only investigation sub-agent spawned by enkly (an agentic coding
assistant) to answer exactly one question about this workspace, then report back. You were
given a single task description as your only input — there is no further conversation, so
make your report self-contained and complete the first time.
## Tools
You have list_directory, glob_search, grep, read_file and search_codebase — nothing that
writes a file, edits a file, or runs a command. You cannot change anything in this
workspace, and nothing you do here needs to be undone.
## How to work
- Orient first (list_directory/glob_search), narrow with grep (exact/lexical) or
search_codebase (fuzzy/conceptual), then read_file the specific files that matter. Don't
read files wholesale "just in case" — every call you make costs context that doesn't
benefit the agent that dispatched you, only you, so stay targeted.
- Investigate until you can answer the task with evidence, not guesses, then stop — more
rounds than necessary just delays the report.
- Write your final answer as a standalone report: lead with the direct answer, then the
specific evidence (file paths, line-level detail, short quoted fragments) that backs it
up. The agent reading your report has none of the context you gathered getting there, so
name files and locations explicitly rather than saying "the file I found."
- If the task is ambiguous or you can't find what it asks for, say so plainly and report
what you did find instead of inventing an answer.
- Write your report in the language the task description was written in — the agent that
dispatched you will relay or translate it for the user as needed.`
// ToolExecutor runs one tool call and returns its result. Satisfied by
// *toolexec.Executor; kept as an interface here purely so agentrun
// doesn't import toolexec directly (and so a test can supply a fake).
type ToolExecutor interface {
Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult
}
// ChatStreamer is the subset of *vllmkit.Client Agent needs. The
// think parameter is a per-call effort override — see
// vllmkit.Client.ChatStream and Agent.ThinkPolicy.
type ChatStreamer interface {
ChatStream(ctx context.Context, messages []vllmkit.Message, tools []vllmkit.ToolDef, think string) (<-chan vllmkit.ChatEvent, error)
}
// Callbacks lets the caller (the application-layer StartTurn use case)
// observe an in-flight run so it can push updates onto a domain.Stream.
type Callbacks struct {
OnToken func(cumulativeText string)
OnThinking func(cumulativeThinking string)
OnToolCall func(call vllmkit.ToolCallRequest, result vllmkit.ToolResult)
// OnThinkingRound, if set, fires once per completed round with just
// that round's own reasoning trace (round is 0-indexed), rather
// than the running cumulative text OnThinking gets — the same
// per-step granularity Claude's interleaved thinking blocks give a
// UI: one distinct block of reasoning per tool-use step, not one
// blob for the whole turn. Optional; nil is a no-op.
OnThinkingRound func(round int, thinking string)
}
// ThinkPolicy decides the per-round thinking-effort override (see
// vllmkit.Client.ChatStream's think parameter: "", "true", "false",
// "low", "medium", "high") passed for one round of an agent run. round
// is 0-indexed; retrying is true when this call is a same-round retry
// after a template-parse hiccup (see Run's attempt loop). Returning ""
// defers entirely to the ChatStreamer's own configured default.
type ThinkPolicy func(round int, retrying bool) string
// DefaultThinkPolicy asks for deeper reasoning on round 0 — the
// planning step where the model has only the user's request and no
// tool results yet, so investing more thinking effort up front pays
// off across the whole turn — and otherwise leaves effort at the
// client's configured default. A retry of the same round (after a
// template-parse hiccup) asks for "low" instead: the goal of a retry
// is a fast, simple re-attempt, not another expensive reasoning pass
// over content that's already a known troublemaker.
func DefaultThinkPolicy(round int, retrying bool) string {
if retrying {
return "low"
}
if round == 0 {
return "high"
}
return ""
}
// Agent ties together the LLM client, the tool executor, and bounds how
// many tool-call round-trips one turn may take.
type Agent struct {
Client ChatStreamer
Executor ToolExecutor
Tools []vllmkit.ToolDef
// MaxRounds matches the monolith's NewAgent default.
MaxRounds int
// SystemPrompt is prepended to every run this Agent executes. Set by
// New (SystemPrompt) or NewSubagent (SubagentSystemPrompt); exported
// so a caller can override it entirely if it ever needs to (tests,
// a future third agent flavor) without touching this package.
SystemPrompt string
// MemoryLoader, if set, is called once at the start of every Run and
// its (trimmed) return value is appended to the system prompt under
// a "Project memory" heading — the mechanism behind the main
// agent's ENKLY.md support (see main.go's wiring). Returning "" (the
// file doesn't exist, or the read failed) adds nothing; a non-nil
// error from the read itself is swallowed by the loader, not
// reported here, so a transient infra hiccup never blocks a turn.
// Left nil for the subagent flavor — a sub-agent gets its task as
// its one input, not the project's standing notes.
MemoryLoader func(ctx context.Context) string
// ThinkPolicy picks the thinking-effort override for each round (see
// the ThinkPolicy type). Defaults to DefaultThinkPolicy in New and
// NewSubagent; a caller (or a test) can override it, and setting it
// to nil falls back to always passing "" (i.e. the ChatStreamer's
// own configured default for every round). When Effort or a
// per-call WithEffort(ctx, ...) is in force, Run derives the actual
// per-round floor from EffortThinkPolicy(profile) instead of using
// this field directly — see resolveEffort and effectiveLimits.
// ThinkPolicy remains the fallback for a run with no effort level
// resolved at all (effort.Default always resolves to one, in
// practice, so this mainly matters for tests that call Run
// directly without going through the Effort machinery).
ThinkPolicy ThinkPolicy
// Effort is this Agent's default reasoning-depth level (see pkg/
// effort) — used whenever a Run call's ctx carries no WithEffort
// override. New and NewSubagent set it to effort.Default, so an
// Agent that never touches effort control behaves exactly as it
// did before this field existed (EffortThinkPolicy(effort.Default.
// Resolve()) reproduces DefaultThinkPolicy's own round-0/retry
// shape, just also carrying Medium's MaxRounds/ContextBudgetChars).
Effort effort.Level
// AdaptiveThink, if set, replaces the per-round floor from Effort
// with AdaptiveThinkPolicy(EffortThinkPolicy(profile)) — see that
// function's doc comment. Left nil by New/NewSubagent; main.go
// turns it on by setting this to agentrun.AdaptiveThinkPolicy (the
// function value, not a call to it — Run calls it per-run with the
// resolved profile's floor). A deployment that wants every turn's
// thinking effort fixed exactly at its level, with no
// content-driven escalation, leaves this nil.
AdaptiveThink func(floor ThinkPolicy) func(round int, retrying bool, sig RoundSignal) string
// ContextBudgetChars, when nonzero, overrides both the package
// default (contextBudgetChars) and whatever pkg/effort's resolved
// profile would otherwise set for a run — see effectiveLimits. Set
// this on a deployment that has measured its own model's usable
// context and wants a hard ceiling regardless of the effort level
// a particular turn happens to run at. Zero (the New/NewSubagent
// default) means "defer entirely to the resolved effort profile".
ContextBudgetChars int
}
func New(client ChatStreamer, executor ToolExecutor, tools []vllmkit.ToolDef) *Agent {
return &Agent{Client: client, Executor: executor, Tools: tools, MaxRounds: 12, SystemPrompt: SystemPrompt, ThinkPolicy: DefaultThinkPolicy, Effort: effort.Default}
}
// NewSubagent builds the restricted Agent a dispatch_subagent call
// runs. It shares the ChatStreamer (vLLM chat-completions requests are
// independent, concurrency-safe round trips) but gets its own tool list — pass
// toolcatalog.SubagentDefs() — its own executor — pass
// toolexec.NewRestricted(mainExecutor) so the read-only boundary is
// enforced even if a future tool list edit slips up — and a smaller
// MaxRounds, since a sub-agent's job is one bounded investigation, not
// an open-ended session.
func NewSubagent(client ChatStreamer, executor ToolExecutor, tools []vllmkit.ToolDef) *Agent {
return &Agent{Client: client, Executor: executor, Tools: tools, MaxRounds: 8, SystemPrompt: SubagentSystemPrompt, ThinkPolicy: DefaultThinkPolicy, Effort: effort.Default}
}
// thinkSelector is a per-round think-effort function that, unlike
// ThinkPolicy, also takes the live RoundSignal Run computed from the
// round that just finished — the hook AdaptiveThinkPolicy needs and a
// plain ThinkPolicy doesn't.
type thinkSelector func(round int, retrying bool, sig RoundSignal) string
// effectiveLimits resolves the MaxRounds/ContextBudgetChars/thinkSelector
// this Run call should actually use: the effort level in force (see
// resolveEffort) sizes all three, a.ContextBudgetChars overrides the
// level's own context budget when explicitly set, and a.MaxRounds is
// used as a hard ceiling the resolved level's MaxRounds can only lower,
// never raise — an operator-configured MaxRounds (e.g. a smaller
// deployment capping every turn at 8 rounds regardless of level) always
// wins over a caller asking for Max effort's 32.
func (a *Agent) effectiveLimits(ctx context.Context) (maxRounds, contextBudget int, think thinkSelector) {
// An Agent that has never been touched by effort control at all —
// built by hand with neither ThinkPolicy nor Effort ever set,
// rather than via New/NewSubagent (both of which always set both)
// — keeps the exact pre-effort-control behavior: a.MaxRounds with
// no clamping, the package's own default context budget, and no
// per-round think override whatsoever. This is what lets
// TestRun_NilThinkPolicyMeansNoOverride-style minimal test/caller
// Agents keep working unchanged; any Agent built via New,
// NewSubagent, or with Effort/ThinkPolicy set some other way opts
// into the full effort-resolved behavior below.
_, ctxHasEffort := effortFrom(ctx)
if a.ThinkPolicy == nil && !a.Effort.Valid() && !ctxHasEffort {
return a.MaxRounds, contextBudgetChars, func(int, bool, RoundSignal) string { return "" }
}
profile := a.resolveEffort(ctx).Resolve()
maxRounds = profile.MaxRounds
if a.MaxRounds > 0 && a.MaxRounds < maxRounds {
maxRounds = a.MaxRounds
}
contextBudget = profile.ContextBudgetChars
if a.ContextBudgetChars > 0 {
contextBudget = a.ContextBudgetChars
}
if contextBudget <= 0 {
contextBudget = contextBudgetChars
}
floor := EffortThinkPolicy(profile)
if a.AdaptiveThink != nil && profile.AdaptiveThinking {
think = a.AdaptiveThink(floor)
} else {
think = func(round int, retrying bool, _ RoundSignal) string { return floor(round, retrying) }
}
return maxRounds, contextBudget, think
}
// signalFromPriorRound builds the RoundSignal for the round about to
// run, from the round immediately before it. round0 (nothing has
// happened yet) always gets the zero RoundSignal — there is nothing
// yet to react to.
func signalFromPriorRound(round, toolCallsSoFar int, priorResults []vllmkit.ToolResult) RoundSignal {
sig := RoundSignal{Round: round, ToolCallsSoFar: toolCallsSoFar}
for _, r := range priorResults {
if r.IsError {
sig.PriorRoundFailed = true
}
if len(r.Content) > sig.PriorToolResultBytes {
sig.PriorToolResultBytes = len(r.Content)
}
}
return sig
}
// memoryPromptChars caps how much of ENKLY.md gets folded into the
// system prompt on every single turn — generous enough for a real
// project-notes file, small enough that a mis-sized or accidentally
// binary-ish file can't blow out the context budget every round.
const memoryPromptChars = 8_000
// buildSystemPrompt returns a.SystemPrompt, plus a "Project memory"
// section from a.MemoryLoader when one is set and returns non-empty
// content.
func (a *Agent) buildSystemPrompt(ctx context.Context) string {
prompt := a.SystemPrompt
if a.MemoryLoader != nil {
if mem := strings.TrimSpace(a.MemoryLoader(ctx)); mem != "" {
if len(mem) > memoryPromptChars {
omitted := len(mem) - memoryPromptChars
mem = fmt.Sprintf("%s\n\n… [%d chars omitted — ENKLY.md is long; ask to read_file it directly if you need the rest]", safeHeadCut(mem, memoryPromptChars), omitted)
}
prompt += "\n\n## Project memory (ENKLY.md, workspace root)\n" + mem
}
}
prompt += languageDirective(preferredLanguageFrom(ctx))
prompt += renderSystemEntries(systemEntriesFrom(ctx))
return prompt
}
// Context budget for a single agent run — see compactToolHistory.
const (
contextBudgetChars = 60_000
keepRecentToolMsgs = 4
compactedHeadChars = 400
compactedTailChars = 400
)
// safeHeadCut and safeTailCut truncate a string to (at most) n bytes
// without two kinds of damage a blind c[:n]/c[len(c)-n:] byte slice
// can do: splitting a multi-byte UTF-8 rune in half (produces invalid
// UTF-8), and — the more consequential one here — splitting an
// angle-bracket tag in half (e.g. a file's own "<div" with no closing
// ">"). This defensive cut point predates the move to vLLM: an
// Ollama-served Qwen deployment's tool-calling chat template wrapped
// tool calls/results in its own XML-ish delimiters and parsed the
// result with Go's encoding/xml on its side, and a dangling,
// unbalanced "<...>" left behind by a mid-tag cut was exactly the kind
// of malformed markup that tripped its parser ("ollama error: XML
// syntax error ... element closed by"). vLLM's OpenAI-compatible
// tool-calling wire format carries tool content as a plain JSON string
// field rather than XML-ish markup threaded through the chat template,
// so that specific parse failure can no longer happen — but snapping
// the cut to the nearest preceding '<' (for a head cut) or following
// '>' (for a tail cut) is still cheap, harmless content hygiene (a
// truncated tool result never hands the model a half-open tag either
// way), so it stays rather than being ripped out for a backend that
// no longer strictly requires it.
func safeHeadCut(s string, n int) string {
if n >= len(s) {
return s
}
cut := n
for cut > 0 && !isRuneBoundary(s, cut) {
cut--
}
if lt := strings.LastIndexByte(s[:cut], '<'); lt >= 0 && cut-lt < 200 {
cut = lt
}
return s[:cut]
}
func safeTailCut(s string, n int) string {
if n >= len(s) {
return s
}
start := len(s) - n
for start < len(s) && !isRuneBoundary(s, start) {
start++
}
if gt := strings.IndexByte(s[start:], '>'); gt >= 0 && gt < 200 {
start += gt + 1
}
return s[start:]
}
func isRuneBoundary(s string, i int) bool {
return i == 0 || i == len(s) || (s[i]&0xC0) != 0x80
}
// degradeRecentToolMessages is the last resort in the tool-call-parse
// self-heal (see the retry loop in Run and vllmkit.IsToolCallParseError):
// when a couple of fresh retries of the exact same round still hit the
// same tool-call parse failure, the most likely culprit is
// specifically the newest tool result(s) in history — whatever just got
// appended is, by construction, the one thing about this request that's
// different from the last request that worked. This blanks the content
// of the last n tool-role messages (in place, in this Run call's local
// messages slice only — never what gets persisted to future turns) down
// to a short placeholder, the same "it's gone, re-call the tool if you
// need it" shape compactToolHistory already uses for ordinary context
// compaction, and returns how many it actually touched.
func degradeRecentToolMessages(messages []vllmkit.Message, n int) int {
degraded := 0
for i := len(messages) - 1; i >= 0 && degraded < n; i-- {
if messages[i].Role != vllmkit.RoleTool {
continue
}
messages[i].Content = fmt.Sprintf(
"[this %s result was removed after it caused a tool-call parsing error; re-call the tool if you need it again]",
messages[i].ToolName,
)
degraded++
}
return degraded
}
// compactToolHistory mutates messages in place, shrinking older large
// tool-result messages once the running total gets large against
// budget (pass contextBudgetChars for the package default; Run passes
// the per-effort-level budget resolved by effectiveLimits instead — see
// Long context management below). Same core algorithm the monolith had
// — unchanged by the service split since it only affects what gets
// resent to the chat model within one run, never what's persisted — now
// returns a one-line description of each message it actually trimmed
// this call, for the caller (Run) to fold into a running compaction
// ledger (see upsertCompactionLedger): "fewer drifts on long agentic
// traces" means a turn that has compacted away its 30th tool result
// should still be working from a stable map of what every earlier
// round *did*, not just silently missing content with no trace it was
// ever there.
func compactToolHistory(messages []vllmkit.Message, budget int) []string {
total := 0
for _, m := range messages {
total += len(m.Content)
}
if total <= budget {
return nil
}
var toolIdx []int
for i, m := range messages {
if m.Role == vllmkit.RoleTool {
toolIdx = append(toolIdx, i)
}
}
if len(toolIdx) <= keepRecentToolMsgs {
return nil
}
var trimmed []string
for _, i := range toolIdx[:len(toolIdx)-keepRecentToolMsgs] {
c := messages[i].Content
if len(c) <= compactedHeadChars+compactedTailChars+64 {
continue
}
omitted := len(c) - compactedHeadChars - compactedTailChars
messages[i].Content = fmt.Sprintf(
"%s\n\n… [%d chars omitted to save context — this was an earlier %s result; re-call it if you need the full output again] …\n\n%s",
safeHeadCut(c, compactedHeadChars), omitted, messages[i].ToolName, safeTailCut(c, compactedTailChars),
)
// vllmkit.Message carries no IsError for a persisted tool
// message (only ToolResult does, before it's folded into
// history), so the ledger records what ran and how much was
// trimmed, not success/failure — Run's own RoundSignal
// tracking (see signalFromPriorRound) is the accurate source
// for failure-driven adaptive thinking; this is purely a
// "what happened, in order" map for the model to re-orient by.
trimmed = append(trimmed, fmt.Sprintf("%s (%d chars omitted)", messages[i].ToolName, omitted))
}
return trimmed
}
// compactionLedgerMarker prefixes the single, stable system message
// upsertCompactionLedger maintains, so Run can find and update it
// again on a later round instead of inserting a new one each time.
const compactionLedgerMarker = "## Compaction ledger (auto-maintained)"
// upsertCompactionLedger folds newly-trimmed descriptions into
// messages' running compaction ledger: a single system-role message,
// inserted immediately after the main system prompt (index 0) the
// first time anything is compacted, and simply re-rendered with a
// larger list on every later round that compacts more — never
// duplicated, never reordered, so a long turn's prefix up through the
// ledger entry is still only ever growing by appended lines, not being
// restructured. entries accumulates across the whole Run call (the
// caller owns and grows the slice); this function only ever rewrites
// the one ledger message's Content from the current full list.
func upsertCompactionLedger(messages []vllmkit.Message, entries []string) []vllmkit.Message {
if len(entries) == 0 {
return messages
}
var b strings.Builder
b.WriteString(compactionLedgerMarker)
b.WriteString("\nEarlier tool results below were shortened to save context. This is the full list of what ran, in " +
"order, so you don't lose track of earlier steps even once their output is gone — re-call a tool if you need " +
"its full output again, don't guess at it from this list.\n")
for i, e := range entries {
fmt.Fprintf(&b, "%d. %s\n", i+1, e)
}
content := strings.TrimRight(b.String(), "\n")
for i := range messages {
if messages[i].Role == vllmkit.RoleSystem && strings.HasPrefix(messages[i].Content, compactionLedgerMarker) {
messages[i].Content = content
return messages
}
}
ledgerMsg := vllmkit.Message{Role: vllmkit.RoleSystem, Content: content}
if len(messages) == 0 {
return append(messages, ledgerMsg)
}
out := make([]vllmkit.Message, 0, len(messages)+1)
out = append(out, messages[0], ledgerMsg)
out = append(out, messages[1:]...)
return out
}
// compactThinkingHistory mutates messages in place, blanking the
// Thinking field on every assistant message except the most recent one
// that still carries both thinking and tool calls. A round's reasoning
// trace has already done its job once its tool call was issued and the
// result appended to history — the model doesn't need round 3's
// thinking resent to it while it's reasoning about round 7, any more
// than Claude's interleaved thinking requires thinking blocks from
// completed turns to be resent (the API only needs the block
// immediately preceding the in-flight tool use). Re-sending every
// round's full reasoning trace on every subsequent round would also
// grow strictly with MaxRounds for no benefit, competing with
// compactToolHistory's tool-result budget for the same context window.
// Unlike compactToolHistory this isn't gated on a size threshold: it
// runs every round, since the only cost of keeping old thinking around
// is wasted context, never lost information the model still needs.
func compactThinkingHistory(messages []vllmkit.Message) {
kept := false
for i := len(messages) - 1; i >= 0; i-- {
m := &messages[i]
if m.Role != vllmkit.RoleAssistant || m.Thinking == "" {
continue
}
if !kept {
kept = true
continue
}
m.Thinking = ""
}
}
// Run executes one full agent turn — same control-flow shape the
// monolith's Agent.Run always had (send history, execute any requested
// tools, feed results back, repeat), now also sizing itself to the
// effort level in force (see effectiveLimits), escalating a round's
// thinking effort on a concrete difficulty signal when adaptive
// thinking is enabled (see signalFromPriorRound/AdaptiveThinkPolicy),
// and maintaining a compaction ledger across however many rounds a
// long run ends up compacting (see upsertCompactionLedger) instead of
// just silently shrinking older tool output with no trace it existed.
func (a *Agent) Run(ctx context.Context, history []vllmkit.Message, cb Callbacks) (finalText string, toPersist []vllmkit.Message, err error) {
messages := make([]vllmkit.Message, len(history))
copy(messages, history)
messages = append([]vllmkit.Message{{Role: vllmkit.RoleSystem, Content: a.buildSystemPrompt(ctx)}}, messages...)
maxRounds, contextBudget, think := a.effectiveLimits(ctx)
var cumulative, cumulativeThinking string
var ledger []string // running "what ran, in order" entries — see upsertCompactionLedger
var priorResults []vllmkit.ToolResult // the just-finished round's tool outcomes, for this round's RoundSignal
toolCallsSoFar := 0
for round := 0; round < maxRounds; round++ {
if trimmed := compactToolHistory(messages, contextBudget); len(trimmed) > 0 {
ledger = append(ledger, trimmed...)
messages = upsertCompactionLedger(messages, ledger)
}
compactThinkingHistory(messages)
sig := signalFromPriorRound(round, toolCallsSoFar, priorResults)
var roundContent, roundThinking string
var roundToolCalls []vllmkit.ToolCallRequest
var roundErr error
// attemptChat runs one ChatStream call against the current
// messages and streams it into roundContent/roundThinking/
// roundToolCalls (resetting them first) and cumulative/
// cumulativeThinking via cb, exactly as a single round always
// has — factored out so the retry and degrade steps below can
// each just call it again against a modified messages, instead
// of duplicating this loop. retrying selects this round's
// thinking-effort override via think (see effectiveLimits,
// EffortThinkPolicy, AdaptiveThinkPolicy) — false for the first
// attempt at this round, true for the template-parse self-heal
// retries below.
attemptChat := func(retrying bool) error {
reqThink := ""
if think != nil {
reqThink = think(round, retrying, sig)
}
events, err := a.Client.ChatStream(ctx, messages, a.Tools, reqThink)
if err != nil {
return fmt.Errorf("agent: round %d: %w", round, err)
}
roundContent, roundThinking, roundToolCalls = "", "", nil
for ev := range events {
if ev.Err != nil {
return fmt.Errorf("agent: round %d: %w", round, ev.Err)
}
if ev.Thinking != "" {
roundThinking += ev.Thinking
if cumulativeThinking != "" && roundThinking == ev.Thinking {
cumulativeThinking += "\n\n"
}
cumulativeThinking += ev.Thinking
if cb.OnThinking != nil {
cb.OnThinking(cumulativeThinking)
}
}
if ev.Token != "" {
roundContent += ev.Token
cumulative += ev.Token
if cb.OnToken != nil {
cb.OnToken(cumulative)
}
}
if len(ev.ToolCalls) > 0 {
roundToolCalls = append(roundToolCalls, ev.ToolCalls...)
}
}
return nil
}
// templateParseRetries bounds the self-heal retry below (see
// vllmkit.IsToolCallParseError's doc comment): a transient
// tool-call parsing hiccup gets a couple of fresh
// attempts at the exact same round before the turn gives up,
// instead of failing on the first occurrence. Any tokens a
// failed attempt already streamed to cb.OnToken/cumulative stay
// visible (there's no clean way to un-stream them from the UI),
// but roundContent/roundToolCalls are reset at the top of each
// attemptChat call, so nothing from a broken attempt leaks into
// messages/toPersist below.
const templateParseRetries = 2
for attempt := 0; ; attempt++ {
roundErr = attemptChat(attempt > 0)
if roundErr == nil || !vllmkit.IsToolCallParseError(roundErr) || attempt >= templateParseRetries || ctx.Err() != nil {
break
}
}
// The plain retries above resend the exact same request, on the
// theory that the failure was a one-off. If it wasn't — the same
// template error survives every attempt — the newest tool
// result(s) in history are the most likely deterministic cause
// (see degradeRecentToolMessages), so blank those and try the
// round fresh exactly once more before giving up the whole turn.
if roundErr != nil && vllmkit.IsToolCallParseError(roundErr) && ctx.Err() == nil {
if degradeRecentToolMessages(messages, keepRecentToolMsgs) > 0 {
roundErr = attemptChat(true)
}
}
if roundErr != nil {
return cumulative, toPersist, roundErr
}
if roundThinking != "" && cb.OnThinkingRound != nil {
cb.OnThinkingRound(round, roundThinking)
}
if err := ctx.Err(); err != nil {
if roundContent != "" {
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent})
}
return cumulative, toPersist, err
}
if len(roundToolCalls) == 0 {
if roundContent != "" {
assistantMsg := vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent}
toPersist = append(toPersist, assistantMsg)
messages = append(messages, assistantMsg)
}
return cumulative, toPersist, nil
}
if roundContent != "" {
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent})
}
messages = append(messages, vllmkit.Message{
Role: vllmkit.RoleAssistant, Content: roundContent, Thinking: roundThinking, ToolCalls: roundToolCalls,
})
results := a.executeToolCalls(ctx, roundToolCalls, cb)
for i := range roundToolCalls {
result := results[i]
toolMsg := vllmkit.Message{Role: vllmkit.RoleTool, Content: result.Content, ToolName: result.Name}
toPersist = append(toPersist, toolMsg)
messages = append(messages, toolMsg)
}
priorResults = results
toolCallsSoFar += len(roundToolCalls)
if ctx.Err() != nil {
return cumulative, toPersist, ctx.Err()
}
}
return cumulative, toPersist, fmt.Errorf("agent: exceeded max rounds (%d) without a final answer", maxRounds)
}
// executeToolCalls runs every call concurrently via the Executor,
// identical fan-out to the monolith's.
func (a *Agent) executeToolCalls(ctx context.Context, calls []vllmkit.ToolCallRequest, cb Callbacks) []vllmkit.ToolResult {
results := make([]vllmkit.ToolResult, len(calls))
if len(calls) == 1 {
results[0] = a.Executor.Execute(ctx, calls[0])
if cb.OnToolCall != nil {
cb.OnToolCall(calls[0], results[0])
}
return results
}
var wg sync.WaitGroup
var mu sync.Mutex
for i := range calls {
wg.Add(1)
go func(i int) {
defer wg.Done()
call := calls[i]
result := a.Executor.Execute(ctx, call)
results[i] = result
if cb.OnToolCall != nil {
mu.Lock()
cb.OnToolCall(call, result)
mu.Unlock()
}
}(i)
}
wg.Wait()
return results
}
// IsCancelled reports whether err represents the run being deliberately
// stopped rather than a real failure.
func IsCancelled(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}

View File

@@ -0,0 +1,247 @@
package agentrun
import (
"context"
"strings"
"testing"
"enkly/pkg/vllmkit"
)
func TestDefaultThinkPolicy(t *testing.T) {
cases := []struct {
name string
round int
retrying bool
want string
}{
{"round 0, fresh attempt: deep thinking", 0, false, "high"},
{"round 0, retry: fast and simple, not deep", 0, true, "low"},
{"later round, fresh attempt: defer to client default", 3, false, ""},
{"later round, retry: still fast and simple", 5, true, "low"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := DefaultThinkPolicy(tc.round, tc.retrying); got != tc.want {
t.Errorf("DefaultThinkPolicy(%d, %v) = %q, want %q", tc.round, tc.retrying, got, tc.want)
}
})
}
}
func TestCompactThinkingHistory(t *testing.T) {
msgs := []vllmkit.Message{
{Role: vllmkit.RoleSystem, Content: "sys"},
{Role: vllmkit.RoleUser, Content: "do the thing"},
{Role: vllmkit.RoleAssistant, Content: "", Thinking: "round0 reasoning", ToolCalls: []vllmkit.ToolCallRequest{{Name: "grep"}}},
{Role: vllmkit.RoleTool, Content: "grep result", ToolName: "grep"},
{Role: vllmkit.RoleAssistant, Content: "", Thinking: "round1 reasoning", ToolCalls: []vllmkit.ToolCallRequest{{Name: "read_file"}}},
{Role: vllmkit.RoleTool, Content: "file contents", ToolName: "read_file"},
}
compactThinkingHistory(msgs)
if msgs[2].Thinking != "" {
t.Errorf("round0's thinking should have been stripped once round1's exists, got %q", msgs[2].Thinking)
}
if msgs[4].Thinking != "round1 reasoning" {
t.Errorf("most recent round's thinking should survive, got %q", msgs[4].Thinking)
}
// Non-thinking fields must be untouched.
if len(msgs[2].ToolCalls) != 1 || msgs[2].ToolCalls[0].Name != "grep" {
t.Errorf("compaction must not touch ToolCalls, got %+v", msgs[2].ToolCalls)
}
if msgs[3].Content != "grep result" {
t.Errorf("compaction must not touch tool messages, got %q", msgs[3].Content)
}
}
func TestCompactThinkingHistory_NoThinkingIsNoop(t *testing.T) {
msgs := []vllmkit.Message{
{Role: vllmkit.RoleUser, Content: "hi"},
{Role: vllmkit.RoleAssistant, Content: "hello"},
}
compactThinkingHistory(msgs)
if msgs[1].Content != "hello" {
t.Errorf("content should be untouched, got %q", msgs[1].Content)
}
}
// fakeChatStreamer scripts one vllmkit.ChatEvent sequence per call,
// in order, and records the think override and a deep-ish snapshot of
// the messages it was called with so tests can assert on both the
// adaptive-thinking policy and the thinking-history compaction working
// together across a multi-round Run.
type fakeChatStreamer struct {
t *testing.T
rounds [][]vllmkit.ChatEvent
call int
gotThink []string
gotMsgs [][]vllmkit.Message
}
func (f *fakeChatStreamer) ChatStream(ctx context.Context, messages []vllmkit.Message, tools []vllmkit.ToolDef, think string) (<-chan vllmkit.ChatEvent, error) {
if f.call >= len(f.rounds) {
f.t.Fatalf("unexpected extra ChatStream call #%d", f.call)
}
f.gotThink = append(f.gotThink, think)
snapshot := make([]vllmkit.Message, len(messages))
copy(snapshot, messages)
f.gotMsgs = append(f.gotMsgs, snapshot)
ch := make(chan vllmkit.ChatEvent, len(f.rounds[f.call])+1)
for _, ev := range f.rounds[f.call] {
ch <- ev
}
close(ch)
f.call++
return ch, nil
}
type fakeExecutor struct{}
func (fakeExecutor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
return vllmkit.ToolResult{Name: call.Name, Content: "ok: " + call.Name}
}
// TestRun_AdaptiveThinkingAndCompaction drives a 3-round turn (two
// tool-call rounds, then a final answer) and checks that: (1)
// ThinkPolicy's per-round effort actually reaches each ChatStream call,
// and (2) by the time round 2's request is built, round 0's thinking
// has been compacted away while round 1's (the most recent) survives —
// i.e. the adaptive-effort and history-compaction pieces work together
// exactly as agent.go wires them.
func TestRun_AdaptiveThinkingAndCompaction(t *testing.T) {
fake := &fakeChatStreamer{
t: t,
rounds: [][]vllmkit.ChatEvent{
{ // round 0: thinks, then calls a tool
{Thinking: "round0 reasoning"},
{ToolCalls: []vllmkit.ToolCallRequest{{Name: "grep", Arguments: map[string]any{}}}},
},
{ // round 1: thinks, then calls another tool
{Thinking: "round1 reasoning"},
{ToolCalls: []vllmkit.ToolCallRequest{{Name: "read_file", Arguments: map[string]any{}}}},
},
{ // round 2: final answer, no tool calls, no thinking
{Token: "done"},
},
},
}
agent := New(fake, fakeExecutor{}, nil)
agent.MemoryLoader = nil
var thinkingRounds []int
var thinkingTexts []string
cb := Callbacks{
OnThinkingRound: func(round int, thinking string) {
thinkingRounds = append(thinkingRounds, round)
thinkingTexts = append(thinkingTexts, thinking)
},
}
final, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "investigate X"}}, cb)
if err != nil {
t.Fatalf("Run: %v", err)
}
if final != "done" {
t.Errorf("final = %q, want %q", final, "done")
}
// ThinkPolicy effort: round 0 fresh attempt -> "high"; round 1 and 2
// (not retries) -> "" (defer to client default), per
// DefaultThinkPolicy.
wantThink := []string{"high", "", ""}
if len(fake.gotThink) != len(wantThink) {
t.Fatalf("got %d ChatStream calls, want %d (think=%v)", len(fake.gotThink), len(wantThink), fake.gotThink)
}
for i, want := range wantThink {
if fake.gotThink[i] != want {
t.Errorf("round %d: think override = %q, want %q", i, fake.gotThink[i], want)
}
}
// OnThinkingRound fired once per round that actually thought.
if len(thinkingRounds) != 2 || thinkingRounds[0] != 0 || thinkingRounds[1] != 1 {
t.Errorf("thinkingRounds = %v, want [0 1]", thinkingRounds)
}
if thinkingTexts[0] != "round0 reasoning" || thinkingTexts[1] != "round1 reasoning" {
t.Errorf("thinkingTexts = %v", thinkingTexts)
}
// History compaction: by round 2's request, round 0's assistant
// message (sent on calls 0 and 1, before round1 even existed) is
// long gone from the picture — what matters is round 2's own
// snapshot, which must carry round1's thinking (still the most
// recent) but not round0's (superseded).
round2Msgs := fake.gotMsgs[2]
var sawRound0Thinking, sawRound1Thinking bool
for _, m := range round2Msgs {
if m.Thinking == "round0 reasoning" {
sawRound0Thinking = true
}
if m.Thinking == "round1 reasoning" {
sawRound1Thinking = true
}
}
if sawRound0Thinking {
t.Errorf("round0's thinking should have been compacted away by round 2's request, got messages: %+v", round2Msgs)
}
if !sawRound1Thinking {
t.Errorf("round1's thinking (most recent) should still be present in round 2's request, got messages: %+v", round2Msgs)
}
}
// TestRun_NilThinkPolicyMeansNoOverride confirms a nil ThinkPolicy (an
// Agent built by hand rather than via New/NewSubagent) falls back to
// always passing "" rather than panicking.
func TestRun_NilThinkPolicyMeansNoOverride(t *testing.T) {
fake := &fakeChatStreamer{
t: t,
rounds: [][]vllmkit.ChatEvent{
{{Token: "hi"}},
},
}
agent := &Agent{Client: fake, Executor: fakeExecutor{}, Tools: nil, MaxRounds: 4, SystemPrompt: SystemPrompt}
_, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "hi"}}, Callbacks{})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(fake.gotThink) != 1 || fake.gotThink[0] != "" {
t.Errorf("gotThink = %v, want [\"\"]", fake.gotThink)
}
}
// TestBuildSystemPrompt_PreferredLanguage confirms a locale attached
// via WithPreferredLanguage reaches the model as an appended
// "## Interface language" section naming that locale's own display
// name, and that a run with no preferred language set (the common
// case — see WithPreferredLanguage's doc comment) sends the base
// SystemPrompt completely unchanged, byte for byte.
func TestBuildSystemPrompt_PreferredLanguage(t *testing.T) {
fake := &fakeChatStreamer{t: t, rounds: [][]vllmkit.ChatEvent{{{Token: "hi"}}}}
agent := &Agent{Client: fake, Executor: fakeExecutor{}, MaxRounds: 4, SystemPrompt: SystemPrompt}
ctx := WithPreferredLanguage(context.Background(), "fr")
if _, _, err := agent.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "bonjour"}}, Callbacks{}); err != nil {
t.Fatalf("Run: %v", err)
}
got := fake.gotMsgs[0][0].Content
if !strings.Contains(got, "## Interface language") || !strings.Contains(got, "Français") {
t.Errorf("system prompt missing French interface-language directive, got suffix: %q", got[len(SystemPrompt):])
}
}
func TestBuildSystemPrompt_NoPreferredLanguageLeavesPromptUnchanged(t *testing.T) {
fake := &fakeChatStreamer{t: t, rounds: [][]vllmkit.ChatEvent{{{Token: "hi"}}}}
agent := &Agent{Client: fake, Executor: fakeExecutor{}, MaxRounds: 4, SystemPrompt: SystemPrompt}
if _, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "hi"}}, Callbacks{}); err != nil {
t.Fatalf("Run: %v", err)
}
if got := fake.gotMsgs[0][0].Content; got != SystemPrompt {
t.Errorf("system prompt changed with no preferred language set")
}
}

View File

@@ -0,0 +1,291 @@
// effort.go adds three related capabilities to the round-trip loop in
// agent.go, all modeled on Claude's agentic-coding feature set:
//
// - Effort control: a caller picks one of pkg/effort's five levels
// for a turn (EffortThinkPolicy, and Run reading a.Effort to size
// MaxRounds/ContextBudgetChars — see Agent.effectiveLimits).
// - Adaptive thinking: instead of (or layered on top of) a fixed
// per-level floor, the model's *own* per-round signal — did the
// last tool call fail? is this a late round in a long turn? — can
// push a round's thinking effort up, so token spend tracks actual
// difficulty rather than one static setting for the whole turn
// (AdaptiveThinkPolicy, RoundSignal).
// - System entries: a caller can hand Run() additional instruction/
// permission/budget text to fold into the system prompt for this
// run, without touching SystemPrompt itself — see WithSystemEntries
// and its doc comment for exactly what "without breaking cache"
// can and can't mean against a vLLM-served backend.
package agentrun
import (
"context"
"fmt"
"strings"
"enkly/pkg/effort"
)
// ---------------------------------------------------------------------
// Effort control
// ---------------------------------------------------------------------
// effortKey is the context.Context key WithEffort stores a level under
// — same unexported-struct-key idiom as preferredLanguageKey.
type effortKey struct{}
// WithEffort attaches the caller's chosen reasoning-depth level to ctx
// for Run to read (see Agent.effectiveLimits and EffortThinkPolicy). An
// unset context (effortFrom returns "", false) means "use the Agent's
// own configured Effort field" — see New's default of effort.Medium —
// so a caller that never touches this gets exactly the behavior the
// agent had before effort control existed.
func WithEffort(ctx context.Context, level effort.Level) context.Context {
return context.WithValue(ctx, effortKey{}, level)
}
func effortFrom(ctx context.Context) (effort.Level, bool) {
l, ok := ctx.Value(effortKey{}).(effort.Level)
return l, ok
}
// EffortFromContext returns the effort level in force on ctx (see
// WithEffort), or effort.Default when none was set. Exported — unlike
// effortFrom — for callers outside this package that need to read the
// resolved level without going through an *Agent (e.g. toolexec's
// dispatch_workflow orchestrator sizing its own parallelism from
// effort.Level.Resolve().MaxParallelSubagents).
func EffortFromContext(ctx context.Context) effort.Level {
if l, ok := effortFrom(ctx); ok && l.Valid() {
return l
}
return effort.Default
}
// resolveEffort picks the level actually in force for one Run call:
// an explicit WithEffort on ctx wins, otherwise the Agent's own
// configured default (a.Effort), otherwise effort.Default.
func (a *Agent) resolveEffort(ctx context.Context) effort.Level {
if l, ok := effortFrom(ctx); ok && l.Valid() {
return l
}
if a.Effort.Valid() {
return a.Effort
}
return effort.Default
}
// EffortThinkPolicy returns a ThinkPolicy that applies profile.Think as
// a floor on every round — never asking for *less* thinking than the
// level calls for — while still preserving DefaultThinkPolicy's two
// situational overrides: round 0 still gets "high" (planning benefits
// from deep thinking regardless of level, unless the level's own floor
// is already "high" or above) and a same-round retry still asks for
// "low" (a fast, simple re-attempt is always what a retry wants, even
// at Max effort — spending more reasoning on a request that just broke
// the chat template isn't going to fix it).
func EffortThinkPolicy(profile effort.Profile) ThinkPolicy {
return func(round int, retrying bool) string {
if retrying {
return "low"
}
if round == 0 && !thinkAtLeast(profile.Think, "high") {
return "high"
}
return profile.Think
}
}
// thinkRank orders the agent's think settings from least to most
// effort, for thinkAtLeast's comparison. Bare "true" (thinking on, no
// explicit level) is treated as equivalent to "medium" — vLLM/Qwen3
// has no server-side notion of graded reasoning depth (see
// vllmkit.Client.Think's doc comment: every non-empty, non-"false"
// setting just toggles chat_template_kwargs.enable_thinking on), so
// this ranking exists purely for agentrun's own floor/escalation
// bookkeeping, not because the wire protocol distinguishes the levels.
var thinkRank = map[string]int{"": 0, "false": 0, "low": 1, "true": 2, "medium": 2, "high": 3}
func thinkAtLeast(setting, floor string) bool {
return thinkRank[strings.ToLower(strings.TrimSpace(setting))] >= thinkRank[floor]
}
// ---------------------------------------------------------------------
// Adaptive thinking
// ---------------------------------------------------------------------
// RoundSignal summarizes what just happened in the previous round of
// the same turn, for AdaptiveThinkPolicy to read when deciding this
// round's effort. Computed by Run from the round it just finished —
// see adaptiveSignalFrom.
type RoundSignal struct {
// Round is the round about to run (0-indexed) — i.e. len(previous
// rounds), not the round the signal describes.
Round int
// PriorRoundFailed is true when the immediately preceding round's
// tool call(s) included at least one IsError result — a concrete
// sign the model is dealing with something that didn't go as
// planned and may benefit from more deliberate reasoning about
// what to try next, rather than a quick pattern-matched retry.
PriorRoundFailed bool
// PriorToolResultBytes is the size of the largest tool result the
// previous round produced. A very large result (a big file, a wide
// grep) is a signal the model now has a lot to digest before its
// next move is obvious.
PriorToolResultBytes int
// ToolCallsSoFar is the running count of tool calls issued earlier
// in this same turn.
ToolCallsSoFar int
}
// adaptiveDeepRoundBytes is the PriorToolResultBytes threshold above
// which AdaptiveThinkPolicy treats "a lot just happened" as a reason to
// think harder about it — calibrated against compactedHeadChars +
// compactedTailChars (800): a result bigger than what compaction would
// eventually trim is, by definition, a substantial one.
const adaptiveDeepRoundBytes = compactedHeadChars + compactedTailChars
// adaptiveLateRoundThreshold is how many tool calls into a turn
// "this is getting long, don't lose the thread" starts to apply.
const adaptiveLateRoundThreshold = 8
// AdaptiveThinkPolicy wraps a floor policy (normally
// EffortThinkPolicy(profile)) so a round whose RoundSignal looks
// difficult gets escalated to "high" even if the floor would have
// allowed less — "the model automatically determines whether to use
// turn-based deep thinking" from a concrete, legible signal rather than
// the model guessing about its own difficulty, since vLLM's
// OpenAI-compatible chat API gives no hook for the model to request
// more effort mid-turn on its own. A round that looks ordinary just gets whatever floor already
// says, so this never spends *less* than the configured level, only
// sometimes more — "optimizing token usage" by reserving the escalation
// for rounds that actually show a difficulty signal, instead of every
// round paying a high-effort tax regardless of how routine it is.
func AdaptiveThinkPolicy(floor ThinkPolicy) func(round int, retrying bool, sig RoundSignal) string {
return func(round int, retrying bool, sig RoundSignal) string {
base := ""
if floor != nil {
base = floor(round, retrying)
}
if retrying {
return base // never escalate a retry — see EffortThinkPolicy's own reasoning
}
if sig.PriorRoundFailed || sig.PriorToolResultBytes > adaptiveDeepRoundBytes || sig.ToolCallsSoFar > adaptiveLateRoundThreshold {
if !thinkAtLeast(base, "high") {
return "high"
}
}
return base
}
}
// ---------------------------------------------------------------------
// System entries: mid-task instruction/permission/budget updates
// ---------------------------------------------------------------------
// SystemEntryKind labels what a SystemEntry is updating, purely for the
// heading it renders under in the system prompt — the model doesn't
// enforce any of these as hard constraints beyond what the prose itself
// says (there's no separate permissions engine here — see toolexec for
// where real enforcement, e.g. the dispatch_subagent read-only
// whitelist, actually lives).
type SystemEntryKind string
const (
// SystemEntryInstruction is a standing instruction added mid-turn
// — e.g. "also update the changelog for every file you touch from
// now on."
SystemEntryInstruction SystemEntryKind = "instruction"
// SystemEntryPermission narrows or widens what the model should
// consider itself allowed to do — e.g. "do not run_bash_command
// against anything under /prod" or "you may now run destructive
// migrations in this workspace, it's a throwaway clone."
SystemEntryPermission SystemEntryKind = "permission"
// SystemEntryBudget communicates a resource constraint — e.g. "you
// have 4 tool calls left before this session's budget runs out,
// wrap up."
SystemEntryBudget SystemEntryKind = "budget"
)
func (k SystemEntryKind) heading() string {
switch k {
case SystemEntryPermission:
return "Permission update"
case SystemEntryBudget:
return "Budget update"
default:
return "Instruction update"
}
}
// SystemEntry is one mid-conversation update a caller can inject into a
// run via WithSystemEntries.
type SystemEntry struct {
Kind SystemEntryKind
Content string
}
// systemEntriesKey is WithSystemEntries' context key.
type systemEntriesKey struct{}
// WithSystemEntries attaches additional system-level entries to ctx for
// Run to fold into the system prompt, appended after everything else
// buildSystemPrompt already assembles (base SystemPrompt, project
// memory, language directive) — see buildSystemPrompt. This is enkly's
// answer to "update instructions, permissions or token budgets in the
// middle of a conversation... without breaking cache": be honest about
// what that can mean here. vLLM has no equivalent of the Anthropic
// API's explicit prompt-cache breakpoints; what it actually does is
// automatic prefix caching (--enable-prefix-caching on the chat
// deployment) — reusing cached KV-cache blocks for however much of the
// new request's token prefix is byte-identical, block-for-block, to a
// previously served request's, across any request that lands on the
// same vLLM replica. The guarantee this function and
// buildSystemPrompt together provide is the one piece of that which is
// actually in enkly's control: every entry is appended, never inserted
// or edited in place, and always after the fixed SystemPrompt + memory
// + language block — so a conversation's prefix up to "## Live session
// updates" stays byte-identical turn over turn whether or not new
// entries have been added, maximizing how much of it a KV-cache
// implementation *can* reuse, rather than scattering updates throughout
// the prompt in a way that would invalidate everything after the first
// change on every single turn.
func WithSystemEntries(ctx context.Context, entries []SystemEntry) context.Context {
if len(entries) == 0 {
return ctx
}
return context.WithValue(ctx, systemEntriesKey{}, entries)
}
func systemEntriesFrom(ctx context.Context) []SystemEntry {
entries, _ := ctx.Value(systemEntriesKey{}).([]SystemEntry)
return entries
}
// maxSystemEntryChars caps one entry's rendered length, for the same
// reason memoryPromptChars caps ENKLY.md: a caller-supplied string
// shouldn't be able to blow out the context budget of every single
// round for the rest of the turn.
const maxSystemEntryChars = 2_000
// renderSystemEntries turns entries into the "## Live session updates"
// section buildSystemPrompt appends. Returns "" for no entries, so
// callers can unconditionally concatenate it.
func renderSystemEntries(entries []SystemEntry) string {
if len(entries) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n## Live session updates\nThe following were added to this session after it started; they take " +
"precedence over anything above that they conflict with, for the remainder of this conversation.\n")
for _, e := range entries {
content := strings.TrimSpace(e.Content)
if content == "" {
continue
}
if len(content) > maxSystemEntryChars {
content = safeHeadCut(content, maxSystemEntryChars) + " […truncated]"
}
fmt.Fprintf(&b, "\n### %s\n%s\n", e.Kind.heading(), content)
}
return b.String()
}

View File

@@ -0,0 +1,82 @@
// Package grpcclients dials every other bounded context the agent
// service's saga needs and exposes each as its generated client
// interface. One file, one place to see the agent service's entire
// synchronous fan-out — see ARCHITECTURE.md's context map diagram.
package grpcclients
import (
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
knowledgev1 "enkly/gen/knowledge/v1"
workspacev1 "enkly/gen/workspace/v1"
)
// Clients bundles every gRPC client connection the agent service holds
// open for the lifetime of the process (one connection each, reused
// across requests — grpc.ClientConn is safe for concurrent use and
// multiplexes calls over HTTP/2 itself, so there's no pool to manage).
type Clients struct {
Workspace workspacev1.WorkspaceServiceClient
Knowledge knowledgev1.KnowledgeServiceClient
Billing billingv1.BillingServiceClient
Conversation conversationv1.ConversationServiceClient
conns []*grpc.ClientConn
}
// Dial connects to every peer service at startup. Addrs are host:port
// (e.g. "workspace:9004"); see cmd/server/main.go for the env vars that
// supply them. Fails fast — the agent service cannot do anything
// useful without all four peers, so there's no point starting the gRPC
// server if one is unreachable (docker compose's restart policy
// handles the retry loop).
func Dial(workspaceAddr, knowledgeAddr, billingAddr, conversationAddr string) (*Clients, error) {
c := &Clients{}
dial := func(addr string) (*grpc.ClientConn, error) {
// Workspace responses can carry a full file's bytes/content
// (uploads, downloads, large reads) — match the server-side
// limit raised in services/workspace/cmd/server/main.go.
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(24<<20), grpc.MaxCallSendMsgSize(24<<20)))
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
c.conns = append(c.conns, conn)
return conn, nil
}
wsConn, err := dial(workspaceAddr)
if err != nil {
return nil, err
}
kConn, err := dial(knowledgeAddr)
if err != nil {
return nil, err
}
bConn, err := dial(billingAddr)
if err != nil {
return nil, err
}
cConn, err := dial(conversationAddr)
if err != nil {
return nil, err
}
c.Workspace = workspacev1.NewWorkspaceServiceClient(wsConn)
c.Knowledge = knowledgev1.NewKnowledgeServiceClient(kConn)
c.Billing = billingv1.NewBillingServiceClient(bConn)
c.Conversation = conversationv1.NewConversationServiceClient(cConn)
return c, nil
}
func (c *Clients) Close() {
for _, conn := range c.conns {
_ = conn.Close()
}
}

View File

@@ -0,0 +1,750 @@
// Package toolcatalog holds the JSON-schema tool definitions sent to
// the vLLM-hosted chat model on every chat request — the exact content of the monolith's
// Toolbox.Defs(), unchanged (the model has been prompted and tested
// against this precise wording; see agent.go's SystemPrompt for the
// companion prose). Presenting tool schemas is the agent service's job
// now, not the workspace service's — the workspace service only
// executes tools once the model has picked one (see
// internal/infrastructure/toolexec).
package toolcatalog
import (
"fmt"
"strings"
"enkly/pkg/mathkit"
"enkly/pkg/skillkit"
"enkly/pkg/vllmkit"
)
// Defs returns every tool the agent may call, in the fixed order the
// monolith declared them.
func Defs() []vllmkit.ToolDef {
return []vllmkit.ToolDef{
{
Name: "read_file",
Description: "Read a text file in the project workspace, with 1-based line numbers prefixed (like `cat -n`). Large files are paginated: use offset/limit to page through them instead of guessing what's relevant.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file, relative to the workspace root.",
},
"offset": map[string]any{
"type": "integer",
"description": "1-based line number to start reading from. Omit to start at line 1.",
},
"limit": map[string]any{
"type": "integer",
"description": "Maximum number of lines to return. Omit for the default (2000).",
},
},
"required": []string{"path"},
},
},
{
Name: "write_file",
Description: "Create a new file, or fully overwrite an existing one, with new content. For changes to an EXISTING file, prefer edit_file — it's cheaper and far less likely to accidentally drop unrelated content than rewriting the whole file.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file, relative to the workspace root.",
},
"content": map[string]any{
"type": "string",
"description": "The full contents to write to the file.",
},
},
"required": []string{"path", "content"},
},
},
{
Name: "edit_file",
Description: "Make a precise, surgical edit to an existing text file by replacing one exact occurrence of old_string with new_string. old_string must match the file's current content exactly (read the file with read_file first, and include enough surrounding context to make it uniquely identify one location) and must not equal new_string. Fails loudly instead of guessing if old_string is missing or ambiguous, which is the whole point: it can't silently clobber the rest of the file the way write_file can. Returns a unified-diff preview of exactly what changed — check it before assuming the edit did what you intended.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file, relative to the workspace root.",
},
"old_string": map[string]any{
"type": "string",
"description": "The exact text to find. Must appear exactly once in the file unless replace_all is true.",
},
"new_string": map[string]any{
"type": "string",
"description": "The text to replace it with.",
},
"replace_all": map[string]any{
"type": "boolean",
"description": "If true, replace every occurrence of old_string instead of requiring exactly one. Defaults to false.",
},
},
"required": []string{"path", "old_string", "new_string"},
},
},
{
Name: "multi_edit_file",
Description: "Apply several edit_file-style replacements to ONE file as a single atomic operation: every edit is validated (found, and unique unless its own replace_all is set) against the file as it would look after the preceding edits in the list, and if ANY edit fails, nothing is written — the file is left untouched, exactly as if none of the edits had been attempted. Prefer this over several separate edit_file calls when you already know multiple changes a single file needs (e.g. renaming a symbol at its definition and at each call site), since it's one round-trip instead of several and never leaves a file half-edited if a later edit in the batch turns out to be wrong. Returns a diff preview per edit, in order.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file, relative to the workspace root.",
},
"edits": map[string]any{
"type": "array",
"description": "The edits to apply, in order. Each is the same shape as edit_file's arguments (minus path).",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"old_string": map[string]any{"type": "string", "description": "The exact text to find."},
"new_string": map[string]any{"type": "string", "description": "The text to replace it with."},
"replace_all": map[string]any{"type": "boolean", "description": "If true, replace every occurrence of this edit's old_string. Defaults to false."},
},
"required": []string{"old_string", "new_string"},
},
},
},
"required": []string{"path", "edits"},
},
},
{
Name: "list_directory",
Description: "List files and subdirectories under a path in the project workspace, as a sorted tree (directories first). Use this to orient yourself in an unfamiliar project before reading or searching files.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Directory to list, relative to the workspace root. Omit or use \".\" for the workspace root.",
},
"max_depth": map[string]any{
"type": "integer",
"description": "How many directory levels deep to recurse. Defaults to 3.",
},
},
},
},
{
Name: "glob_search",
Description: "Find files by name pattern (e.g. \"**/*.go\", \"internal/**/handlers/*.go\", \"*.md\"). `*` matches within one path segment, `**` matches across segments. Faster and more precise than list_directory when you know roughly what filename or extension you're looking for.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Glob pattern, relative to the workspace root.",
},
},
"required": []string{"pattern"},
},
},
{
Name: "grep",
Description: "Search file contents across the workspace for a regular expression, returning matching file:line:text results. Use this for exact/lexical lookups (a symbol name, an error string, a TODO) — use search_codebase instead for fuzzy, conceptual ('where do we handle X') queries.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "RE2 regular expression to search for.",
},
"path_glob": map[string]any{
"type": "string",
"description": "Optional glob to restrict which files are searched, e.g. \"*.go\".",
},
"case_insensitive": map[string]any{
"type": "boolean",
"description": "Defaults to false.",
},
},
"required": []string{"pattern"},
},
},
{
Name: "write_todos",
Description: "Write or replace your working plan as a structured todo list, shown to the user as a checklist. Use it to break a nontrivial, multi-step request (more than ~3 steps, or anything touching several files) into tracked steps *before* starting work, and call it again to flip items to in_progress/completed as you go. Skip it for a single small, obvious change — it adds overhead, not clarity, on a one-step task.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"todos": map[string]any{
"type": "array",
"description": "The full current plan (this replaces any previous list, so include every item, not just the ones that changed).",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
"status": map[string]any{
"type": "string",
"enum": []string{"pending", "in_progress", "completed"},
},
},
"required": []string{"content", "status"},
},
},
},
"required": []string{"todos"},
},
},
{
Name: "run_bash_command",
Description: "Run one command in the project workspace and return its combined stdout/stderr. Commands run directly WITHOUT a shell: no pipes, redirects, globbing, chaining or substitution (quote arguments containing special characters with single quotes). Only allowlisted programs run (go build/test/vet/fmt/list, git read-only subcommands, ls, cat, grep, find, gofmt); all paths must stay inside the workspace. After editing code, prefer running the relevant build/tests here over asserting the change is correct.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"description": "A single command line, e.g. `go test ./...` or `grep -rn TODO .`.",
},
},
"required": []string{"command"},
},
},
{
Name: "search_codebase",
Description: "Semantic search over the indexed codebase using pgvector. Returns the most relevant code chunks for a natural-language, conceptual query.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "Natural-language description of the code you're looking for.",
},
},
"required": []string{"query"},
},
},
{
Name: "solve_ml_math",
Description: "Solve a concrete mathematics problem from the four pillars that come up when building, " +
"debugging or explaining ML code: linear algebra, calculus (derivatives/gradients/integrals), " +
"probability & statistics, numerical optimization, plus ML-specific primitives (activations, loss " +
"functions, least-squares linear regression). Computes an exact or numerically-verified answer " +
"locally — pure Go arithmetic, not the model's own mental math — so prefer this over working " +
"something out by hand whenever a request has an actual numeric or symbolic-in-form-but-numeric " +
"answer (\"what's the gradient of this loss at this point\", \"invert this matrix\", \"is this " +
"activation numerically stable\", \"fit a line through these points\"). Not for code generation or " +
"explaining a concept in prose — just for computing an answer. Supported \"operation\" values: " +
strings.Join(mathkit.Operations(), ", ") + ". Each operation's required parameters:\n" +
"- evaluate{expression, variables?}: arithmetic/function expression, e.g. \"sigmoid(w*x+b)\".\n" +
"- matrix_add/matrix_sub/matrix_mul{a, b}, matrix_transpose/matrix_determinant/matrix_inverse/" +
"matrix_rank/matrix_trace{a}: 2D arrays, e.g. a=[[1,2],[3,4]].\n" +
"- dot_product{a, b}, vector_norm{a, p?}: 1D arrays; p defaults to 2 (Euclidean).\n" +
"- dominant_eigen{a, iterations?}: largest-eigenvalue/eigenvector via power iteration.\n" +
"- derivative{expression, variable, at|value, h?}, gradient{expression, variables, at}, " +
"integral{expression, variable, from, to, steps?}: at is {\"name\": number, ...}.\n" +
"- gradient_descent{expression, variables, start, learning_rate, iterations?, tolerance?}: " +
"runs plain gradient descent and returns the trajectory.\n" +
"- newton_root{expression, variable, start, iterations?, tolerance?}: finds a root via Newton's method.\n" +
"- mean/variance/std_dev/median{data, population?}, covariance/correlation{x, y, population?}, " +
"z_score_normalize/min_max_normalize{data}, normal_pdf/normal_cdf{x, mean?, std_dev?}, " +
"binomial_pmf{k, n, p}.\n" +
"- sigmoid/relu/tanh{x}: x is a number or an array. softmax{x}: array. " +
"mse_loss/cross_entropy_loss{predictions, targets}. " +
"linear_regression{x, y}: x is a 2D feature matrix (rows=observations), y is the target vector; " +
"fits by ordinary least squares and reports coefficients, R², and residuals.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"operation": map[string]any{
"type": "string",
"description": "Which calculation to run. See the tool description for the full list and each one's required fields.",
},
"expression": map[string]any{
"type": "string",
"description": "An infix math expression for operations that take one (evaluate, derivative, gradient, integral, gradient_descent, newton_root). Supports + - * / ^, parentheses, variables, the constants pi/e, and functions sin/cos/tan/asin/acos/atan/sinh/cosh/tanh/exp/sqrt/abs/ln/log/log2/log10/floor/ceil/round/sigmoid/relu/sign/max/min/pow/mod/atan2.",
},
"variable": map[string]any{"type": "string", "description": "The single variable of differentiation/integration/root-finding (derivative, integral, newton_root)."},
"variables": map[string]any{
"description": "Either the ordered list of variable names for a multivariate operation (gradient, gradient_descent — an array of strings), OR a name->value object of extra bound variables an expression needs (evaluate, integral).",
},
"at": map[string]any{"type": "object", "description": "name->number bindings at which to evaluate a derivative/gradient, e.g. {\"x\": 1, \"y\": 2}."},
"value": map[string]any{"type": "number", "description": "Shorthand for \"at\" when derivative has exactly one variable."},
"h": map[string]any{"type": "number", "description": "Finite-difference step size for derivative/gradient. Omit for a sensible default."},
"from": map[string]any{"type": "number", "description": "Lower bound of a definite integral."},
"to": map[string]any{"type": "number", "description": "Upper bound of a definite integral."},
"steps": map[string]any{"type": "integer", "description": "Subintervals for integral (Simpson's rule). Omit for a sensible default."},
"start": map[string]any{"description": "Starting point: an array of numbers (gradient_descent, one per variable) or a single number (newton_root)."},
"learning_rate": map[string]any{"type": "number", "description": "Step size for gradient_descent. Required, must be positive."},
"iterations": map[string]any{"type": "integer", "description": "Max steps for gradient_descent/newton_root/dominant_eigen. Omit for a sensible default."},
"tolerance": map[string]any{"type": "number", "description": "Early-stop threshold for gradient_descent (gradient norm) or newton_root (|f(x)|)."},
"a": map[string]any{"description": "A matrix (2D array, e.g. [[1,2],[3,4]]) or vector (1D array), depending on the operation."},
"b": map[string]any{"description": "A second matrix or vector, same shape rules as \"a\"."},
"p": map[string]any{"type": "number", "description": "Which norm for vector_norm: 1, 2 (default), or a large number to approximate infinity."},
"data": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "A 1D array of numbers for a statistics operation."},
"x": map[string]any{
"description": "Context-dependent: a number or array for sigmoid/relu/tanh/softmax/normal_pdf/normal_cdf, a data array for covariance/correlation, or a 2D feature matrix for linear_regression.",
},
"y": map[string]any{"description": "A data array for covariance/correlation, or the target vector for linear_regression."},
"population": map[string]any{
"type": "boolean",
"description": "variance/std_dev/covariance: true for the population formula (divide by n), false (default) for the unbiased sample formula (divide by n-1).",
},
"mean": map[string]any{"type": "number", "description": "Distribution mean for normal_pdf/normal_cdf. Defaults to 0."},
"std_dev": map[string]any{"type": "number", "description": "Distribution standard deviation for normal_pdf/normal_cdf. Defaults to 1."},
"k": map[string]any{"type": "integer", "description": "Number of successes for binomial_pmf."},
"n": map[string]any{"type": "integer", "description": "Number of trials for binomial_pmf."},
"predictions": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Model outputs for mse_loss/cross_entropy_loss."},
"targets": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Ground-truth values for mse_loss/cross_entropy_loss."},
},
"required": []string{"operation"},
},
},
{
Name: "read_office_skill",
Description: officeSkillToolDescription(),
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"enum": skillkit.Names(),
"description": "Which skill to read. Start with \"overview\" if you're not sure which document format applies.",
},
},
"required": []string{"name"},
},
},
{
Name: "search_office_skills",
Description: "Semantically search the office-document skills (docx/xlsx/pptx/overview) for the specific " +
"sections most relevant to a question — e.g. \"can a cell hold a formula\" or \"how do I bold part " +
"of a paragraph\" — instead of reading a whole skill file to find one answer. Returns the top " +
"matching sections, each labeled with the skill and heading it came from, ranked by relevance. " +
"Prefer this over read_office_skill for a narrow, specific question; prefer read_office_skill when " +
"you want a skill's complete guidance before a multi-step document task.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "A specific question or short description of what you need to know."},
"skill": map[string]any{
"type": "string",
"enum": skillkit.Names(),
"description": "Optional: restrict the search to one skill. Omit to search across all of them.",
},
},
"required": []string{"query"},
},
},
// --- Document management (Word/Excel/PowerPoint), modeled on
// Claude Code's document-editing skills: the model only ever
// supplies plain structured fields (titles, paragraph text,
// cell values, bullet lines) — never raw OOXML/XML markup. All
// nine tools below are implemented by pkg/officekit, which does
// 100% of the actual XML generation/parsing server-side using
// Go's typed XML encoder (always well-formed, always correctly
// escaped). This is a deliberate safety property, not just a
// convenience: an earlier, Ollama-backed version of this service
// saw XML text round-tripped through a tool-call argument or tool
// result trigger "ollama error: XML syntax error ... element
// closed by" — Qwen's tool-calling chat template wrapped tool
// calls/results in its own XML-ish delimiters that Ollama parsed
// on its side, and a stray or truncated angle bracket in echoed
// content could break that parse. The vLLM OpenAI-compatible
// tool-calling wire format this service now speaks (pkg/vllmkit)
// carries tool calls/results as JSON instead, so that exact
// failure mode is gone, but keeping documents structured-in/
// structured-out remains the right call regardless of transport:
// the model never needs to emit markup at all. See
// agentrun/agent.go's compactToolHistory and vllmkit/client.go's
// IsToolCallParseError for the matching defensive fixes on
// whatever transport-level parse failures remain.
{
Name: "create_document",
Description: "Create a new Word document (.docx) in the workspace, either from a list of structured " +
"paragraphs (\"paragraphs\") or by converting an existing Markdown document (\"markdown\") straight " +
"into one — headings, body text, bullets. Fails if the path already exists; use edit_document to " +
"change one. The model never writes OOXML by hand: give plain text and a style per paragraph (or " +
"just Markdown source) and this tool renders a real, valid .docx file. For an ordinary document (a " +
"CV, cover letter, memo, report, README-style page), this description is everything you need — go " +
"straight to calling it, no read_office_skill round-trip required: with \"paragraphs\", lead with " +
"one heading1 for the title, use heading2/heading3 for section headers, bullet for genuinely " +
"list-shaped content (not as a stand-in for paragraph breaks), and normal for body text — split body " +
"text into real paragraphs by idea, not one giant block; with \"markdown\", just pass the source " +
"text and #/##/### headings, paragraphs and -/* lists map onto the same styles automatically. This " +
"tool can't yet do tables, images, headers/footers, or bold/italic applied to only part of a " +
"paragraph (bold/italic/underline apply to the whole paragraph; Markdown's inline **bold**/*italic* " +
"spans are flattened to plain text for the same reason); say so plainly if someone asks for one of " +
"those rather than approximating it. Reach for read_office_skill only if a request needs one of " +
"those unsupported features confirmed, edit_document's replace_text nuances, or Track Changes " +
"redlining (edit_document's *_tracked operations).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .docx."},
"paragraphs": map[string]any{
"type": "array",
"description": "The document's content, in order. Omit if using \"markdown\" instead.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "The paragraph's plain text."},
"style": map[string]any{"type": "string", "enum": []string{"normal", "heading1", "heading2", "heading3", "bullet"}, "description": "Defaults to \"normal\"."},
"bold": map[string]any{"type": "boolean", "description": "Bold the whole paragraph. Defaults to false; implied true for headings."},
"italic": map[string]any{"type": "boolean", "description": "Italicize the whole paragraph. Defaults to false."},
"underline": map[string]any{"type": "boolean", "description": "Underline the whole paragraph. Defaults to false."},
},
"required": []string{"text"},
},
},
"markdown": map[string]any{
"type": "string",
"description": "Alternative to \"paragraphs\": a complete Markdown document to convert directly into the .docx (headings, paragraphs, bullet/numbered lists). Use this instead of hand-building a paragraphs array when you already have — or would naturally write — Markdown source.",
},
},
"required": []string{"path"},
},
},
{
Name: "read_document",
Description: "Read a Word document (.docx) back as plain text, one line per paragraph (bullets prefixed \"- \"). Works on any standard .docx, including ones a user uploaded, though complex formatting, tables and images aren't represented — just the text.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Path to the .docx file, relative to the workspace root."},
},
"required": []string{"path"},
},
},
{
Name: "edit_document",
Description: "Make a structured change to an existing Word document. Plain edits: append_paragraph (add " +
"one new paragraph at the end, same fields as create_document's paragraphs) or replace_text (replace " +
"every literal occurrence of \"find\" with \"replace\" across the document's text, including text " +
"inside tables — the structured, markup-free analogue of edit_file's old_string/new_string; unlike " +
"edit_file every occurrence is replaced, and a phrase split across more than one formatting run by " +
"the document's own edit history may not be found as a single match). Redlining (Track Changes): " +
"append_paragraph_tracked and replace_text_tracked do the same edits but record them as a " +
"reviewable, Word-native tracked change (shown struck-through/underlined under Review > Track " +
"Changes) instead of rewriting the text in place — use these whenever someone asks for \"tracked " +
"changes\", \"redlining\", \"suggest an edit\", or a change that should go through review before it's " +
"final; accept_all_changes and reject_all_changes then resolve every pending tracked change in the " +
"document at once (there's no per-change accept/reject yet — say so if someone asks for that). " +
"Read the document first if you're not sure what's in it or whether it already has pending tracked " +
"changes (read_document reports them).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Path to the .docx file, relative to the workspace root."},
"operation": map[string]any{"type": "string", "enum": []string{
"append_paragraph", "replace_text",
"append_paragraph_tracked", "replace_text_tracked",
"accept_all_changes", "reject_all_changes",
}},
"text": map[string]any{"type": "string", "description": "append_paragraph/append_paragraph_tracked: the new paragraph's text."},
"style": map[string]any{"type": "string", "enum": []string{"normal", "heading1", "heading2", "heading3", "bullet"}, "description": "append_paragraph/append_paragraph_tracked only. Defaults to \"normal\"."},
"bold": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false; implied true for headings."},
"italic": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false."},
"underline": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false."},
"find": map[string]any{"type": "string", "description": "replace_text/replace_text_tracked: exact text to find."},
"replace": map[string]any{"type": "string", "description": "replace_text/replace_text_tracked: its replacement."},
"author": map[string]any{"type": "string", "description": "append_paragraph_tracked/replace_text_tracked only: the reviewer name recorded on the tracked change. Defaults to \"enkly agent\"."},
},
"required": []string{"path", "operation"},
},
},
{
Name: "create_spreadsheet",
Description: "Create a new Excel workbook (.xlsx) in the workspace, either as one sheet (\"rows\") or " +
"several (\"sheets\"). Each row is a list of cell values, top to bottom, left to right (row 1 = A1, " +
"B1, ...); a value starting with \"=\" is stored as a live Excel formula (e.g. \"=SUM(A1:A4)\"), " +
"everything else that parses as a plain number is stored as a numeric cell, and anything left over " +
"as text. Fails if the path already exists; use edit_spreadsheet to change individual cells of an " +
"existing one, or read_spreadsheet first to see what's there.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .xlsx."},
"sheet_name": map[string]any{"type": "string", "description": "Single-sheet form only (with \"rows\"). Defaults to \"Sheet1\"."},
"rows": map[string]any{
"type": "array",
"description": "Single-sheet form: row-major grid of cell values, e.g. [[\"Name\",\"Score\"],[\"Alice\",\"92\"]]. Omit if using \"sheets\" instead.",
"items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
"sheets": map[string]any{
"type": "array",
"description": "Multi-sheet form: a workbook with one tab per entry, in order. Use this instead of \"rows\"/\"sheet_name\" when the workbook needs more than one sheet.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string", "description": "This sheet's tab name."},
"rows": map[string]any{
"type": "array",
"description": "This sheet's row-major grid of cell values.",
"items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
},
"required": []string{"rows"},
},
},
},
"required": []string{"path"},
},
},
{
Name: "read_spreadsheet",
Description: "Read one sheet of an Excel workbook (.xlsx) back as a row-major text table. Defaults to " +
"the first sheet; when the workbook has more than one, the result's first line names the sheet " +
"read and lists every sheet's name so you can ask for a different one. Works on any standard " +
".xlsx, including ones a user uploaded.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Path to the .xlsx file, relative to the workspace root."},
"sheet": map[string]any{"type": "string", "description": "Which sheet to read: a 1-based index (\"2\") or a sheet name. Omit for the first sheet."},
},
"required": []string{"path"},
},
},
{
Name: "edit_spreadsheet",
Description: "Write a sparse set of individual cells into one sheet of an existing Excel workbook, " +
"by A1-style reference (e.g. \"B12\"). Leaves every other cell untouched; a reference past the " +
"current grid extends it. Read the spreadsheet first if you need to know what's already there or " +
"what the other sheets are called.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Path to the .xlsx file, relative to the workspace root."},
"sheet": map[string]any{"type": "string", "description": "Which sheet to write to: a 1-based index (\"2\") or a sheet name. Omit for the first sheet."},
"cells": map[string]any{
"type": "array",
"description": "The cells to write.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"cell": map[string]any{"type": "string", "description": "A1-style reference, e.g. \"B12\"."},
"value": map[string]any{"type": "string", "description": "New value. A leading \"=\" stores a live formula (e.g. \"=SUM(B2:B11)\"); otherwise stored as a number if it parses as one, else as text."},
},
"required": []string{"cell", "value"},
},
},
},
"required": []string{"path", "cells"},
},
},
{
Name: "create_presentation",
Description: "Create a new PowerPoint deck (.pptx) in the workspace from a list of slides, each a title " +
"plus flat bullet lines — no layouts or manual positioning to specify. Fails if the path already " +
"exists; use edit_presentation to add or change a slide afterward.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .pptx."},
"slides": map[string]any{
"type": "array",
"description": "The deck's slides, in order.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{"type": "string"},
"bullets": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
},
},
},
"required": []string{"path", "slides"},
},
},
{
Name: "read_presentation",
Description: "Read a PowerPoint deck (.pptx) back as a list of slides (title + bullets), in order. Works on any standard .pptx, including ones a user uploaded, though complex layouts/images aren't represented.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Path to the .pptx file, relative to the workspace root."},
},
"required": []string{"path"},
},
},
{
Name: "edit_presentation",
Description: "Either add_slide (append one new slide at the end — same title/bullets shape as " +
"create_presentation's slides) or update_slide (replace one existing slide's title/bullets by its " +
"1-based index) in an existing PowerPoint deck. Read the presentation first if you're not sure how " +
"many slides it has.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Path to the .pptx file, relative to the workspace root."},
"operation": map[string]any{"type": "string", "enum": []string{"add_slide", "update_slide"}},
"index": map[string]any{"type": "integer", "description": "update_slide only: 1-based slide number to replace."},
"title": map[string]any{"type": "string"},
"bullets": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
"required": []string{"path", "operation"},
},
},
{
Name: "read_pdf",
Description: "Extract the plain text content of a PDF file, best-effort — data extraction from PDFs, " +
"for reading an uploaded report/contract/paper rather than producing one. Works well on PDFs with a " +
"real embedded text layer (almost anything produced by Word, LibreOffice, a browser's \"Print to " +
"PDF\", Pandoc, or similar tools). It cannot do OCR: a scanned or image-only PDF will come back with " +
"an error saying so, not fabricated text. Layout, tables, columns, and images are not represented — " +
"only the words, roughly in reading order, with best-effort line breaks; don't rely on this for a " +
"document's exact formatting or table structure.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Path to the .pdf file, relative to the workspace root."},
},
"required": []string{"path"},
},
},
{
Name: "dispatch_subagent",
Description: "Launch an autonomous, read-only sub-agent (modeled after Claude Code's Task tool) to investigate " +
"the workspace for you and report back. Give it one self-contained, specific task description — it has its " +
"own fresh context and only list_directory, glob_search, grep, read_file and search_codebase (no write_file, " +
"edit_file, multi_edit_file or run_bash_command: it can never change anything). It explores on its own for up " +
"to a few rounds and returns a single written report; everything it reads along the way stays in ITS context, " +
"not yours. Reach for this instead of a long manual chain of your own search/read calls when a question is " +
"broad or open-ended (\"find every place X happens and summarize the pattern\", \"map out how auth flows " +
"through this service\", \"is there already code that does Y?\") — one dispatch_subagent call often replaces a " +
"dozen of your own tool calls and keeps your own context focused on deciding what to do with the answer. " +
"Don't use it for a single targeted lookup you could do yourself in one or two calls, and never for anything " +
"that needs to write or run something — it can't.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{
"type": "string",
"description": "A precise, self-contained description of what to investigate and what the report should " +
"cover. The sub-agent has no memory of this conversation beyond this one string, so include every " +
"detail it needs (which directory or feature, what you already know, what you want answered).",
},
},
"required": []string{"task"},
},
},
{
Name: "dispatch_workflow",
Description: "Fan a large task out into many independent sub-agents running IN PARALLEL (modeled after " +
"Claude's dynamic multi-agent orchestration), then collect, verify and summarize what came back — the " +
"tool to reach for on a large-scale, mechanically-repeated job like a codebase migration (\"rename this " +
"API across every service\", \"add a context.Context first param to every exported func in these 40 " +
"files\", \"port every handler in internal/old to the new router\"), not a single investigation " +
"(use dispatch_subagent for that). Give it a short list of self-contained steps; each one runs as its " +
"own read-only sub-agent, with the same tool access as dispatch_subagent (it investigates and reports — " +
"it does not write or run anything, same as dispatch_subagent, so plan the actual edits as a follow-up " +
"once you've seen every step's findings, exactly the way you'd use any other investigation's report). " +
"Steps with no listed dependencies start immediately and run concurrently, bounded by this deployment's " +
"configured parallelism cap; a step that lists depends_on waits for those steps to finish first, so you " +
"can express \"investigate every call site\" as 40 fully-parallel steps while still sequencing a final " +
"\"cross-check the findings against each other\" step after them. Every step's report is automatically " +
"checked against its own task description (did it actually answer what was asked, or come back empty/" +
"off-topic) before you see the results, so a weak report is flagged for you to re-dispatch or handle " +
"yourself instead of silently passing as done — this is the real-time verification and plan adjustment " +
"loop: look at which steps verified cleanly and which didn't, and adjust what you do next accordingly " +
"rather than assuming every step succeeded. Not available on every plan — if it's not enabled for this " +
"account, the tool call itself will say so; fall back to dispatch_subagent (sequential, same " +
"investigative power, just one step at a time) or to doing the work directly.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"goal": map[string]any{
"type": "string",
"description": "One or two sentences on the overall objective these steps serve — included in each " +
"step's own task so no sub-agent loses the forest for the trees.",
},
"steps": map[string]any{
"type": "array",
"description": "The independent (or dependency-ordered) units of work to run, 2 to several hundred.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{
"type": "string",
"description": "A short, unique identifier for this step (e.g. \"svc-billing\", \"file-12\"), referenced by other steps' depends_on and by the final summary.",
},
"task": map[string]any{
"type": "string",
"description": "A precise, self-contained description of this one step's work and what its report should cover — same guidance as dispatch_subagent's task argument.",
},
"depends_on": map[string]any{
"type": "array",
"description": "IDs of steps that must finish before this one starts. Omit for a step with no dependencies (the common case — most migration steps are independent of each other).",
"items": map[string]any{"type": "string"},
},
},
"required": []string{"id", "task"},
},
},
},
"required": []string{"goal", "steps"},
},
},
}
}
// officeSkillToolDescription builds read_office_skill's description
// from the currently-embedded skill files, so the catalog and the
// skills (pkg/skillkit/skills/*.md) can never drift apart — adding or
// renaming a skill file is all that's needed to change what the model
// sees here, no second edit to keep in sync.
func officeSkillToolDescription() string {
var b strings.Builder
b.WriteString("Read one of enkly's office-document skills: short notes on how to use the Word/Excel/" +
"PowerPoint tools well — conventions, best practices, and what each one honestly can't do yet. The " +
"create_document/create_spreadsheet/create_presentation tool descriptions already cover the basics " +
"needed for an ordinary document, so this is optional, not a required first step — skip it for a " +
"straightforward request (a CV, a simple report, a one-sheet table of numbers) and go straight to the " +
"create_*/edit_* tool; reach for this before anything that leans on a format's sharper edges (Excel " +
"formulas or a multi-sheet workbook, a multi-slide PowerPoint deck, edit_document's replace_text run- " +
"splitting caveat, redlining with Track Changes, or converting Markdown straight into a .docx) or when " +
"you're not sure a feature someone asked for is even supported. Start with the " +
"\"overview\" skill if you're not sure which format a request needs. No need to re-read a skill you've " +
"already read earlier in this same conversation. Available skills:\n")
for _, s := range skillkit.List() {
fmt.Fprintf(&b, "- %s: %s\n", s.Name, s.Description)
}
return strings.TrimRight(b.String(), "\n")
}
// subagentToolNames is the read-only whitelist offered to a
// dispatch_subagent run: everything that can observe the workspace,
// nothing that can change it. toolexec.NewRestricted enforces this
// same whitelist at execution time — belt and suspenders, since a
// tool catalog only controls what the model is *offered*, not what it
// could still try to call.
var subagentToolNames = map[string]bool{
"read_file": true,
"list_directory": true,
"glob_search": true,
"grep": true,
"search_codebase": true,
"read_document": true,
"read_spreadsheet": true,
"read_presentation": true,
"read_office_skill": true,
"search_office_skills": true,
"read_pdf": true,
}
// SubagentDefs returns the read-only subset of Defs() handed to a
// dispatch_subagent run.
func SubagentDefs() []vllmkit.ToolDef {
all := Defs()
out := make([]vllmkit.ToolDef, 0, len(subagentToolNames))
for _, d := range all {
if subagentToolNames[d.Name] {
out = append(out, d)
}
}
return out
}
// IsSubagentTool reports whether name is in the read-only whitelist —
// used by toolexec.restrictedExecutor so the enforcement lives next to
// the single source of truth for the whitelist itself.
func IsSubagentTool(name string) bool {
return subagentToolNames[name]
}

View File

@@ -0,0 +1,316 @@
// Package toolexec is the direct replacement for the monolith's
// Toolbox.Execute dispatch switch. Where that switch called a method on
// the same in-process Toolbox for every tool, Executor.Execute calls
// out over gRPC to whichever bounded context now owns that tool
// (workspace for every filesystem/bash tool, knowledge for
// search_codebase) or runs write_todos and solve_ml_math locally (both
// are pure, in-memory computation with no filesystem/db dependency —
// write_todos per the monolith's tools.go.writeTodos, and
// solve_ml_math because pkg/mathkit is a self-contained numerical
// engine with nothing to call out to; see mlmath.go).
//
// Every method here turns a transport-level error (the peer being
// unreachable, timing out, ...) into an IsError ToolResult rather than
// letting it propagate as a Go error — exactly the contract
// Toolbox.Execute already had ("errors executing the tool are turned
// into an IsError ToolResult... so the agent loop can feed the failure
// back to the model and let it try again"), now also covering RPC
// failures, which didn't exist as a failure mode in the monolith.
package toolexec
import (
"context"
"fmt"
"strings"
knowledgev1 "enkly/gen/knowledge/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/skillkit"
"enkly/pkg/vllmkit"
"enkly/services/agent/internal/infrastructure/agentrun"
)
type Executor struct {
Workspace workspacev1.WorkspaceServiceClient
Knowledge knowledgev1.KnowledgeServiceClient
// SkillIndex backs the search_office_skills tool (see
// office_skill.go): an in-memory, lazily-embedded semantic index
// over pkg/skillkit's office-document skill content. Nil when New
// was given a nil embedder (e.g. a deployment with no embedding
// model configured) — search_office_skills reports itself
// unavailable rather than panicking, the same nil-is-graceful
// contract Subagent below has.
SkillIndex *skillkit.Index
// Subagent powers both the dispatch_subagent tool (see subagent.go)
// and dispatch_workflow (see workflow.go), which reuses this same
// nested Agent for every one of its parallel steps rather than
// building a separate one per step — sub.Run is safe to call
// concurrently (its ChatStreamer and Executor are both stateless
// gRPC/HTTP clients). Left nil by New — main.go sets it in a
// second step, after building an Executor, because the sub-agent's
// own executor (toolexec.NewRestricted) needs to wrap this very
// Executor by pointer. Nil is handled gracefully: both tools just
// report themselves unavailable rather than panicking.
Subagent *agentrun.Agent
// WorkflowMaxParallel caps how many dispatch_workflow steps may run
// concurrently in one call, before the resolved effort level's own
// (smaller, per-level) cap is also applied — see dispatchWorkflow.
// Zero (the New default) falls back to defaultWorkflowMaxParallel.
// Set from ENKLY_WORKFLOW_MAX_PARALLEL in main.go; this is the hard
// operational ceiling for a deployment's hardware, independent of
// what any single turn's effort level would otherwise allow.
WorkflowMaxParallel int
// WorkflowVerifier checks each dispatch_workflow step's report —
// see the WorkflowVerifier type. Nil (the New default) falls back
// to defaultWorkflowVerifier.
WorkflowVerifier WorkflowVerifier
}
// defaultWorkflowMaxParallel is a conservative default for a single
// vLLM chat replica. Unlike Ollama (which serves one model-inference
// request at a time in practice, however many HTTP requests arrive
// concurrently), vLLM's continuous-batching scheduler genuinely
// services many in-flight requests on one GPU at once, batching new
// tokens for whichever sequences are ready each scheduler step — so a
// production vLLM deployment can sustain meaningfully higher
// concurrent dispatch_workflow fan-out than an Ollama-backed one ever
// could on the same hardware, bounded in practice by
// --max-num-seqs/--gpu-memory-utilization on the chat deployment (see
// k8s/11-vllm.yaml) rather than by the engine serializing requests.
// This default stays deliberately conservative rather than assuming
// every deployment's chat replica count and --max-num-seqs can absorb
// a much larger number — see cmd/server/main.go's
// ENKLY_WORKFLOW_MAX_PARALLEL for how a deployment with real
// multi-GPU/multi-replica vLLM capacity raises this toward the
// "hundreds" a cluster-backed deployment can actually make good use
// of.
const defaultWorkflowMaxParallel = 64
// New wires an Executor to its peer services plus, if embedder is
// non-nil, the office-skills semantic index (see SkillIndex). embedder
// is typically the same *vllmkit.Client already used for chat — it
// satisfies skillkit.Embedder as-is, no adapter needed.
func New(workspace workspacev1.WorkspaceServiceClient, knowledge knowledgev1.KnowledgeServiceClient, embedder skillkit.Embedder) *Executor {
e := &Executor{Workspace: workspace, Knowledge: knowledge}
if embedder != nil {
e.SkillIndex = skillkit.NewIndex(embedder)
}
return e
}
func rpcFail(name string, err error) vllmkit.ToolResult {
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf("%s: service unavailable: %v", name, err), IsError: true}
}
// Execute dispatches a single tool call, mirroring Toolbox.Execute's
// switch one case at a time.
func (e *Executor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
switch call.Name {
case "read_file":
return e.readFile(ctx, call)
case "write_file":
return e.writeFile(ctx, call)
case "edit_file":
return e.editFile(ctx, call)
case "multi_edit_file":
return e.multiEditFile(ctx, call)
case "list_directory":
return e.listDirectory(ctx, call)
case "glob_search":
return e.globSearch(ctx, call)
case "grep":
return e.grep(ctx, call)
case "write_todos":
return writeTodos(call)
case "run_bash_command":
return e.runBashCommand(ctx, call)
case "search_codebase":
return e.searchCodebase(ctx, call)
case "solve_ml_math":
return solveMLMath(call)
case "dispatch_subagent":
return e.dispatchSubagent(ctx, call)
case "dispatch_workflow":
return e.dispatchWorkflow(ctx, call)
case "read_office_skill":
return readOfficeSkill(call)
case "search_office_skills":
return e.searchOfficeSkills(ctx, call)
case "create_document":
return e.createDocument(ctx, call)
case "read_document":
return e.readDocument(ctx, call)
case "edit_document":
return e.editDocument(ctx, call)
case "create_spreadsheet":
return e.createSpreadsheet(ctx, call)
case "read_spreadsheet":
return e.readSpreadsheet(ctx, call)
case "edit_spreadsheet":
return e.editSpreadsheet(ctx, call)
case "create_presentation":
return e.createPresentation(ctx, call)
case "read_presentation":
return e.readPresentation(ctx, call)
case "edit_presentation":
return e.editPresentation(ctx, call)
case "read_pdf":
return e.readPdf(ctx, call)
default:
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("unknown tool %q", call.Name), IsError: true}
}
}
func argStr(args map[string]any, key string) string {
v, _ := args[key].(string)
return v
}
func argInt(args map[string]any, key string) int {
switch v := args[key].(type) {
case float64:
return int(v)
case int:
return v
default:
return 0
}
}
func argBool(args map[string]any, key string) bool {
v, _ := args[key].(bool)
return v
}
func (e *Executor) readFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
resp, err := e.Workspace.ReadFile(ctx, &workspacev1.ReadFileRequest{
Path: argStr(call.Arguments, "path"), Offset: int32(argInt(call.Arguments, "offset")), Limit: int32(argInt(call.Arguments, "limit")),
})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetContent()}
}
func (e *Executor) writeFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
resp, err := e.Workspace.WriteFile(ctx, &workspacev1.WriteFileRequest{
Path: argStr(call.Arguments, "path"), Content: argStr(call.Arguments, "content"),
})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d bytes to %s", resp.GetBytesWritten(), argStr(call.Arguments, "path"))}
}
func (e *Executor) editFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
resp, err := e.Workspace.EditFile(ctx, &workspacev1.EditFileRequest{
Path: argStr(call.Arguments, "path"), OldString: argStr(call.Arguments, "old_string"),
NewString: argStr(call.Arguments, "new_string"), ReplaceAll: argBool(call.Arguments, "replace_all"),
})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetDiffPreview()}
}
func (e *Executor) multiEditFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
rawEdits, _ := call.Arguments["edits"].([]any)
edits := make([]*workspacev1.TextEdit, 0, len(rawEdits))
for _, re := range rawEdits {
m, ok := re.(map[string]any)
if !ok {
continue
}
edits = append(edits, &workspacev1.TextEdit{
OldString: argStr(m, "old_string"), NewString: argStr(m, "new_string"), ReplaceAll: argBool(m, "replace_all"),
})
}
resp, err := e.Workspace.MultiEditFile(ctx, &workspacev1.MultiEditFileRequest{
Path: argStr(call.Arguments, "path"), Edits: edits,
})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetDiffPreview()}
}
func (e *Executor) listDirectory(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
resp, err := e.Workspace.ListDirectory(ctx, &workspacev1.ListDirectoryRequest{Path: argStr(call.Arguments, "path")})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetListing()}
}
func (e *Executor) globSearch(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
resp, err := e.Workspace.GlobSearch(ctx, &workspacev1.GlobSearchRequest{Pattern: argStr(call.Arguments, "pattern")})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
}
func (e *Executor) grep(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
resp, err := e.Workspace.Grep(ctx, &workspacev1.GrepRequest{
Pattern: argStr(call.Arguments, "pattern"), Glob: argStr(call.Arguments, "path_glob"),
CaseInsensitive: argBool(call.Arguments, "case_insensitive"),
})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
}
func (e *Executor) runBashCommand(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
resp, err := e.Workspace.RunBashCommand(ctx, &workspacev1.RunBashCommandRequest{Command: argStr(call.Arguments, "command")})
if err != nil {
return rpcFail(call.Name, err)
}
if resp.GetIsError() {
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
}
func (e *Executor) searchCodebase(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
query := argStr(call.Arguments, "query")
if strings.TrimSpace(query) == "" {
return vllmkit.ToolResult{Name: call.Name, Content: "search_codebase: empty query", IsError: true}
}
resp, err := e.Knowledge.Search(ctx, &knowledgev1.SearchRequest{Query: query, TopK: 6})
if err != nil {
return rpcFail(call.Name, err)
}
if len(resp.GetChunks()) == 0 {
return vllmkit.ToolResult{Name: call.Name, Content: "no matching code found"}
}
var sb strings.Builder
for _, c := range resp.GetChunks() {
fmt.Fprintf(&sb, "### %s (chunk %d, distance %.4f)\n```\n%s\n```\n\n", c.GetFilePath(), c.GetChunkIndex(), c.GetDistance(), c.GetContent())
}
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
}

View File

@@ -0,0 +1,29 @@
package toolexec
import (
"enkly/pkg/mathkit"
"enkly/pkg/vllmkit"
)
// solveMLMath runs the solve_ml_math tool. Like write_todos, this is
// pure, in-memory computation (arithmetic over the arguments the
// model already supplied) with no filesystem, database, or other
// service dependency, so — unlike every tool in executor.go that
// calls out to the workspace or knowledge service — it never leaves
// this process. See pkg/mathkit/solve.go for the operation dispatch
// table and pkg/mathkit's package doc for the math it covers.
func solveMLMath(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
op := argStr(call.Arguments, "operation")
if op == "" {
return vllmkit.ToolResult{
Name: call.Name,
Content: `solve_ml_math: missing "operation" — see the tool description for the full list of supported operations`,
IsError: true,
}
}
result, err := mathkit.Solve(op, call.Arguments)
if err != nil {
return vllmkit.ToolResult{Name: call.Name, Content: "solve_ml_math: " + err.Error(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: result}
}

View File

@@ -0,0 +1,451 @@
// Document-management tools (create/read/edit_document,
// create/read/edit_spreadsheet, create/read/edit_presentation) —
// Word/Excel/PowerPoint support modeled on Claude Code's
// document-editing skills. All the actual OOXML work happens in
// pkg/officekit; this file just translates tool-call arguments to/from
// it and persists bytes through the workspace service's existing
// UploadFile/DownloadFile RPCs (binary-safe []byte fields — unlike
// ReadFile/WriteFile's text-oriented string fields, which real .docx/
// .xlsx/.pptx bytes wouldn't survive intact).
//
// Deliberately, nothing here ever puts raw XML into a ToolResult.Content
// or reads raw XML out of call.Arguments — every value crossing that
// boundary is plain text (a title, a paragraph, a cell value, a short
// summary like "wrote report.docx (4 paragraphs)"). That's what keeps
// these new tools from being a new way to trigger a tool-call parse
// failure on the model's output — the historical Ollama chat-template
// "XML syntax error" failure mode described in catalog.go's comment
// above these tool defs, or vLLM's own tool-call parser choking on
// unexpected markup in an argument or result.
package toolexec
import (
"context"
"fmt"
"strings"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/officekit"
"enkly/pkg/vllmkit"
)
func (e *Executor) download(ctx context.Context, path string) ([]byte, error) {
resp, err := e.Workspace.DownloadFile(ctx, &workspacev1.DownloadFileRequest{Path: path})
if err != nil {
return nil, err
}
return resp.GetContent(), nil
}
func (e *Executor) upload(ctx context.Context, path string, data []byte) error {
_, err := e.Workspace.UploadFile(ctx, &workspacev1.UploadFileRequest{Path: path, Content: data})
return err
}
// errResult is a small helper matching the rest of this package's
// vllmkit.ToolResult{IsError: true} convention.
func errResult(name, format string, a ...any) vllmkit.ToolResult {
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf(format, a...), IsError: true}
}
// --- Word ---------------------------------------------------------
func parseParagraphArg(m map[string]any) officekit.Paragraph {
style := officekit.ParaStyle(argStr(m, "style"))
if style == "" {
style = officekit.StyleNormal
}
return officekit.Paragraph{
Text: argStr(m, "text"),
Style: style,
Bold: argBool(m, "bold"),
Italic: argBool(m, "italic"),
Underline: argBool(m, "underline"),
}
}
func (e *Executor) createDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
if !strings.HasSuffix(strings.ToLower(path), ".docx") {
return errResult(call.Name, "create_document: path must end in .docx, got %q", path)
}
if existing, _ := e.download(ctx, path); len(existing) > 0 {
return errResult(call.Name, "create_document: %s already exists; use edit_document to change it", path)
}
var paras []officekit.Paragraph
if md := argStr(call.Arguments, "markdown"); md != "" {
// Conversion path: let officekit's Markdown parser do the
// heading/bullet/paragraph structuring instead of the model
// building "paragraphs" entries by hand — see markdown.go's
// package doc for exactly what carries over and what doesn't.
paras = officekit.MarkdownToParagraphs(md)
if len(paras) == 0 {
return errResult(call.Name, "create_document: markdown produced no recognizable content (headings/paragraphs/lists)")
}
} else {
rawParas, _ := call.Arguments["paragraphs"].([]any)
paras = make([]officekit.Paragraph, 0, len(rawParas))
for _, rp := range rawParas {
if m, ok := rp.(map[string]any); ok {
paras = append(paras, parseParagraphArg(m))
}
}
}
data, err := officekit.BuildDocx(paras)
if err != nil {
return errResult(call.Name, "create_document: %v", err)
}
if err := e.upload(ctx, path, data); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d paragraph(s), %d bytes)", path, len(paras), len(data))}
}
func (e *Executor) readDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_document: %s not found or empty", path)
}
text, err := officekit.ReadDocxText(data)
if err != nil {
return errResult(call.Name, "read_document: %v", err)
}
// Surface any pending tracked changes alongside the plain text so
// a caller reviewing a document doesn't need a second tool call to
// discover it has open redlines — see redline.go's ReadDocxRevisions.
if revisions, rerr := officekit.ReadDocxRevisions(data); rerr == nil && revisions != "" {
text += "\n\n--- tracked changes pending review (edit_document: accept_all_changes / reject_all_changes) ---\n" + revisions
}
return vllmkit.ToolResult{Name: call.Name, Content: text}
}
func (e *Executor) editDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "edit_document: %s not found", path)
}
switch argStr(call.Arguments, "operation") {
case "append_paragraph":
p := parseParagraphArg(call.Arguments)
out, err := officekit.AppendDocxParagraph(data, p)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s", path)}
case "replace_text":
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
if find == "" {
return errResult(call.Name, "edit_document: replace_text requires a non-empty \"find\"")
}
out, n, err := officekit.ReplaceDocxText(data, find, replace)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("replaced text in %d paragraph(s) of %s", n, path)}
case "replace_text_tracked":
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
if find == "" {
return errResult(call.Name, "edit_document: replace_text_tracked requires a non-empty \"find\"")
}
out, n, err := officekit.ReplaceDocxTextTracked(data, find, replace, argStr(call.Arguments, "author"))
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("tracked a change in %d run(s) of %s, pending review (Word: Review > Track Changes)", n, path)}
case "append_paragraph_tracked":
p := parseParagraphArg(call.Arguments)
out, err := officekit.AppendDocxParagraphTracked(data, p, argStr(call.Arguments, "author"))
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s as a tracked insertion, pending review", path)}
case "accept_all_changes":
out, err := officekit.AcceptAllDocxChanges(data)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("accepted all tracked changes in %s", path)}
case "reject_all_changes":
out, err := officekit.RejectAllDocxChanges(data)
if err != nil {
return errResult(call.Name, "edit_document: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("rejected all tracked changes in %s (original text restored)", path)}
default:
return errResult(call.Name, "edit_document: unknown operation %q (expected append_paragraph, replace_text, append_paragraph_tracked, replace_text_tracked, accept_all_changes, or reject_all_changes)", argStr(call.Arguments, "operation"))
}
}
// --- Excel ----------------------------------------------------------
func (e *Executor) createSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
if !strings.HasSuffix(strings.ToLower(path), ".xlsx") {
return errResult(call.Name, "create_spreadsheet: path must end in .xlsx, got %q", path)
}
if existing, _ := e.download(ctx, path); len(existing) > 0 {
return errResult(call.Name, "create_spreadsheet: %s already exists; use edit_spreadsheet to change it", path)
}
sheets := parseSheetsArg(call.Arguments)
if len(sheets) == 0 {
return errResult(call.Name, "create_spreadsheet: give either \"rows\" (one sheet) or \"sheets\" (multiple)")
}
data, err := officekit.BuildXlsx(sheets)
if err != nil {
return errResult(call.Name, "create_spreadsheet: %v", err)
}
if err := e.upload(ctx, path, data); err != nil {
return rpcFail(call.Name, err)
}
totalRows := 0
for _, s := range sheets {
totalRows += len(s.Rows)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d sheet(s), %d row(s) total, %d bytes)", path, len(sheets), totalRows, len(data))}
}
// parseSheetsArg accepts either the multi-sheet "sheets" array
// (each {name, rows}) or the original single-sheet "rows"/"sheet_name"
// pair, so existing callers keep working unchanged while the model can
// now also build a workbook with more than one tab in one call.
func parseSheetsArg(args map[string]any) []officekit.Sheet {
if rawSheets, ok := args["sheets"].([]any); ok && len(rawSheets) > 0 {
sheets := make([]officekit.Sheet, 0, len(rawSheets))
for _, rs := range rawSheets {
m, ok := rs.(map[string]any)
if !ok {
continue
}
sheets = append(sheets, officekit.Sheet{Name: argStr(m, "name"), Rows: parseRowsArg(m["rows"])})
}
return sheets
}
if _, has := args["rows"]; !has {
return nil
}
return []officekit.Sheet{{Name: argStr(args, "sheet_name"), Rows: parseRowsArg(args["rows"])}}
}
func parseRowsArg(v any) [][]string {
rawRows, _ := v.([]any)
rows := make([][]string, 0, len(rawRows))
for _, rr := range rawRows {
rawRow, _ := rr.([]any)
row := make([]string, 0, len(rawRow))
for _, c := range rawRow {
s, _ := c.(string)
row = append(row, s)
}
rows = append(rows, row)
}
return rows
}
func (e *Executor) readSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_spreadsheet: %s not found or empty", path)
}
sheetRef := argStr(call.Arguments, "sheet")
rows, sheetName, err := officekit.ReadXlsxCells(data, sheetRef)
if err != nil {
return errResult(call.Name, "read_spreadsheet: %v", err)
}
var sb strings.Builder
if all, nameErr := officekit.ReadXlsxSheetNames(data); nameErr == nil && len(all) > 1 {
fmt.Fprintf(&sb, "# sheet %q of %d: %s\n", sheetName, len(all), strings.Join(all, ", "))
}
for _, row := range rows {
sb.WriteString(strings.Join(row, "\t"))
sb.WriteByte('\n')
}
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
}
func (e *Executor) editSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "edit_spreadsheet: %s not found", path)
}
rawCells, _ := call.Arguments["cells"].([]any)
writes := make(map[string]string, len(rawCells))
for _, rc := range rawCells {
m, ok := rc.(map[string]any)
if !ok {
continue
}
writes[strings.ToUpper(argStr(m, "cell"))] = argStr(m, "value")
}
if len(writes) == 0 {
return errResult(call.Name, "edit_spreadsheet: no cells given")
}
sheetRef := argStr(call.Arguments, "sheet")
out, err := officekit.SetXlsxCells(data, sheetRef, writes)
if err != nil {
return errResult(call.Name, "edit_spreadsheet: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d cell(s) to %s", len(writes), path)}
}
// --- PowerPoint -------------------------------------------------------
func parseSlideArg(m map[string]any) officekit.Slide {
rawBullets, _ := m["bullets"].([]any)
bullets := make([]string, 0, len(rawBullets))
for _, b := range rawBullets {
if s, ok := b.(string); ok {
bullets = append(bullets, s)
}
}
return officekit.Slide{Title: argStr(m, "title"), Bullets: bullets}
}
func (e *Executor) createPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
if !strings.HasSuffix(strings.ToLower(path), ".pptx") {
return errResult(call.Name, "create_presentation: path must end in .pptx, got %q", path)
}
if existing, _ := e.download(ctx, path); len(existing) > 0 {
return errResult(call.Name, "create_presentation: %s already exists; use edit_presentation to change it", path)
}
rawSlides, _ := call.Arguments["slides"].([]any)
slides := make([]officekit.Slide, 0, len(rawSlides))
for _, rs := range rawSlides {
if m, ok := rs.(map[string]any); ok {
slides = append(slides, parseSlideArg(m))
}
}
data, err := officekit.BuildPptx(slides)
if err != nil {
return errResult(call.Name, "create_presentation: %v", err)
}
if err := e.upload(ctx, path, data); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d slide(s), %d bytes)", path, len(slides), len(data))}
}
func (e *Executor) readPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_presentation: %s not found or empty", path)
}
slides, err := officekit.ReadPptxSlides(data)
if err != nil {
return errResult(call.Name, "read_presentation: %v", err)
}
var sb strings.Builder
for i, s := range slides {
fmt.Fprintf(&sb, "## Slide %d: %s\n", i+1, s.Title)
for _, b := range s.Bullets {
fmt.Fprintf(&sb, "- %s\n", b)
}
sb.WriteByte('\n')
}
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
}
func (e *Executor) editPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "edit_presentation: %s not found", path)
}
slide := parseSlideArg(call.Arguments)
switch argStr(call.Arguments, "operation") {
case "add_slide":
out, err := officekit.AppendPptxSlide(data, slide)
if err != nil {
return errResult(call.Name, "edit_presentation: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("added 1 slide to %s", path)}
case "update_slide":
idx := argInt(call.Arguments, "index")
out, err := officekit.UpdatePptxSlide(data, idx, slide)
if err != nil {
return errResult(call.Name, "edit_presentation: %v", err)
}
if err := e.upload(ctx, path, out); err != nil {
return rpcFail(call.Name, err)
}
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("updated slide %d of %s", idx, path)}
default:
return errResult(call.Name, "edit_presentation: unknown operation %q (expected add_slide or update_slide)", argStr(call.Arguments, "operation"))
}
}
// --- PDF (read-only: data extraction, not creation) --------------------
func (e *Executor) readPdf(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
path := argStr(call.Arguments, "path")
data, err := e.download(ctx, path)
if err != nil {
return rpcFail(call.Name, err)
}
if len(data) == 0 {
return errResult(call.Name, "read_pdf: %s not found or empty", path)
}
text, err := officekit.ReadPdfText(data)
if err != nil {
return errResult(call.Name, "read_pdf: %v", err)
}
return vllmkit.ToolResult{Name: call.Name, Content: text}
}

View File

@@ -0,0 +1,60 @@
// read_office_skill and search_office_skills are the two office-skill
// tools that aren't a gRPC call or an officekit XML operation.
// read_office_skill is pure, in-memory lookup into pkg/skillkit's
// embedded skill files (the same category as write_todos and
// solve_ml_math — no workspace/knowledge dependency, see executor.go's
// package doc); search_office_skills calls out to the vLLM-hosted
// embedding model (via Executor.SkillIndex) to embed the query, same
// as search_codebase
// calling out to the knowledge service — see rag.go's package doc for
// why this one stays in-process instead.
package toolexec
import (
"context"
"fmt"
"strings"
"enkly/pkg/skillkit"
"enkly/pkg/vllmkit"
)
func readOfficeSkill(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
name := argStr(call.Arguments, "name")
skill, ok := skillkit.Read(name)
if !ok {
available := skillkit.Names()
return errResult(call.Name, "read_office_skill: no skill named %q (available: %s)", name, strings.Join(available, ", "))
}
return vllmkit.ToolResult{Name: call.Name, Content: skill.Body}
}
func (e *Executor) searchOfficeSkills(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
query := strings.TrimSpace(argStr(call.Arguments, "query"))
if query == "" {
return errResult(call.Name, "search_office_skills: empty query")
}
if e.SkillIndex == nil {
return errResult(call.Name, "search_office_skills: not available on this deployment (no embedding model configured); use read_office_skill instead")
}
skillFilter := argStr(call.Arguments, "skill")
if skillFilter != "" {
if _, ok := skillkit.Read(skillFilter); !ok {
return errResult(call.Name, "search_office_skills: no skill named %q (available: %s)", skillFilter, strings.Join(skillkit.Names(), ", "))
}
}
matches, err := e.SkillIndex.Search(ctx, query, 4, skillFilter)
if err != nil {
return errResult(call.Name, "search_office_skills: %v", err)
}
if len(matches) == 0 {
return vllmkit.ToolResult{Name: call.Name, Content: "no matching guidance found"}
}
var sb strings.Builder
for _, m := range matches {
fmt.Fprintf(&sb, "### %s — %s (similarity %.3f)\n%s\n\n", m.Skill, m.Heading, m.Score, m.Text)
}
return vllmkit.ToolResult{Name: call.Name, Content: strings.TrimRight(sb.String(), "\n")}
}

View File

@@ -0,0 +1,84 @@
// subagent.go implements the dispatch_subagent tool: the agent
// service's equivalent of Claude Code's Task tool. The main agent
// hands off a self-contained investigation to a nested agentrun.Agent
// (see agentrun.NewSubagent) that runs with its own, much smaller tool
// list and reports back in one string, so a broad exploration ("find
// every place X happens") spends *its* context budget, not the
// conversation's.
package toolexec
import (
"context"
"fmt"
"strings"
"enkly/pkg/vllmkit"
"enkly/services/agent/internal/infrastructure/agentrun"
"enkly/services/agent/internal/infrastructure/toolcatalog"
)
// restrictedExecutor wraps an *Executor but only forwards calls whose
// name is in toolcatalog's read-only whitelist, failing closed on
// everything else (including, explicitly, dispatch_subagent itself —
// a sub-agent can't spawn another one). This is the second of two
// enforcement layers: toolcatalog.SubagentDefs() controls what the
// model is *offered*, this controls what actually runs if the model
// (or a future bug) asks for something outside that list anyway.
type restrictedExecutor struct {
inner *Executor
}
// NewRestricted returns a ToolExecutor that only ever runs the
// read-only tools in toolcatalog.SubagentDefs(), whatever inner
// supports. Used to build the dispatch_subagent sub-agent's executor.
func NewRestricted(inner *Executor) *restrictedExecutor {
return &restrictedExecutor{inner: inner}
}
func (r *restrictedExecutor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
if !toolcatalog.IsSubagentTool(call.Name) {
return vllmkit.ToolResult{
Name: call.Name,
Content: fmt.Sprintf("%s: not available to a sub-agent (read-only tools only)", call.Name),
IsError: true,
}
}
return r.inner.Execute(ctx, call)
}
// dispatchSubagent runs the dispatch_subagent tool call: it launches
// e.Subagent on a single user message (the "task" argument) and feeds
// its final answer back as this tool call's result. e.Subagent is nil
// until main.go wires it (see its comment there for why that can't
// happen inside toolexec.New) — guarded here so a misconfigured
// deployment degrades to a clear tool error instead of a nil-pointer
// panic reaching the model's request.
func (e *Executor) dispatchSubagent(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
if e.Subagent == nil {
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: sub-agent not configured on this deployment", IsError: true}
}
task := strings.TrimSpace(argStr(call.Arguments, "task"))
if task == "" {
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: task is required", IsError: true}
}
report, _, err := e.Subagent.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: task}}, agentrun.Callbacks{})
if err != nil {
// A cancelled sub-agent (the whole turn was stopped, or it hit
// its own round cap mid-investigation) still gets a useful
// result back: whatever it had produced, plus a clear note —
// not a bare tool error that discards partial progress.
report = strings.TrimSpace(report)
note := fmt.Sprintf("\n\n[sub-agent stopped early: %v]", err)
if report == "" {
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: " + err.Error(), IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: report + note}
}
report = strings.TrimSpace(report)
if report == "" {
report = "(the sub-agent finished without writing a report — try a more specific task description)"
}
return vllmkit.ToolResult{Name: call.Name, Content: report}
}

View File

@@ -0,0 +1,429 @@
// workflow.go implements the dispatch_workflow tool: enkly's answer to
// "dynamic workflows... orchestration of hundreds of subagents in
// parallel in a single session, with automatic verification of results
// and real-time plan adjustment." Where dispatch_subagent (subagent.go)
// hands off ONE investigation and waits for it, dispatch_workflow takes
// a whole batch of them — typically one per file, service, or call site
// in a large mechanical job like a codebase migration — and runs as
// many as the deployment's configured (and plan-entitled) parallelism
// allows at once, respects any depends_on ordering between them, checks
// every report against its own task before handing results back, and
// retries a step once if that check fails, instead of silently treating
// an empty or off-topic report as done.
//
// Every step is, under the hood, exactly the same kind of run
// dispatch_subagent launches — e.Subagent.Run against a fresh,
// read-only sub-agent — just many of them in flight at once instead of
// one. That reuse is deliberate: it's the same nested agentrun.Agent,
// the same restrictedExecutor read-only boundary, the same
// SubagentSystemPrompt, so a workflow step can never write or run
// anything any more than a lone dispatch_subagent call could. What
// *is* new here is the scheduling, the bounded concurrency, and the
// verification pass — everything below this point in the file.
package toolexec
import (
"context"
"fmt"
"strings"
"sync"
"enkly/pkg/entitlements"
"enkly/pkg/vllmkit"
"enkly/services/agent/internal/infrastructure/agentrun"
)
// ---------------------------------------------------------------------
// Entitlements: whether dispatch_workflow is usable this turn
// ---------------------------------------------------------------------
// entitlementsKey is WithEntitlements' context key.
type entitlementsKey struct{}
// WithEntitlements attaches the caller's resolved capability Set (see
// pkg/entitlements) to ctx for dispatchWorkflow to read back — set once
// per turn by application.Service.StartTurn from the plan name
// billing's CheckAndIncrementUsage already returned, the same place
// agentrun.WithPreferredLanguage and agentrun.WithEffort get attached
// to the same run context.
func WithEntitlements(ctx context.Context, set entitlements.Set) context.Context {
return context.WithValue(ctx, entitlementsKey{}, set)
}
// entitlementsFrom reads back what WithEntitlements stored. The zero
// Set (DynamicWorkflows: false) is the correct default for a context
// nothing ever attached one to — see entitlements.Catalog.Resolve's
// doc comment on failing closed.
func entitlementsFrom(ctx context.Context) entitlements.Set {
set, _ := ctx.Value(entitlementsKey{}).(entitlements.Set)
return set
}
// ---------------------------------------------------------------------
// dispatch_workflow argument parsing
// ---------------------------------------------------------------------
type workflowStep struct {
ID string
Task string
DependsOn []string
}
// maxWorkflowSteps bounds one dispatch_workflow call regardless of
// configured parallelism — "hundreds" of concurrent sub-agents is the
// target, not an unbounded number typed into one tool call; well past
// this a migration should be split into more than one dispatch_workflow
// round anyway (the model can always call it again).
const maxWorkflowSteps = 500
func parseWorkflowSteps(raw []any) ([]workflowStep, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("dispatch_workflow: \"steps\" must be a non-empty array")
}
if len(raw) > maxWorkflowSteps {
return nil, fmt.Errorf("dispatch_workflow: %d steps exceeds the %d-step limit for one call; split into more than one dispatch_workflow", len(raw), maxWorkflowSteps)
}
seen := make(map[string]bool, len(raw))
steps := make([]workflowStep, 0, len(raw))
for i, item := range raw {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("dispatch_workflow: step %d is not an object", i)
}
id := strings.TrimSpace(argStr(m, "id"))
task := strings.TrimSpace(argStr(m, "task"))
if id == "" {
return nil, fmt.Errorf("dispatch_workflow: step %d is missing \"id\"", i)
}
if task == "" {
return nil, fmt.Errorf("dispatch_workflow: step %q is missing \"task\"", id)
}
if seen[id] {
return nil, fmt.Errorf("dispatch_workflow: duplicate step id %q", id)
}
seen[id] = true
var deps []string
if rawDeps, ok := m["depends_on"].([]any); ok {
for _, d := range rawDeps {
if s, ok := d.(string); ok && strings.TrimSpace(s) != "" {
deps = append(deps, strings.TrimSpace(s))
}
}
}
steps = append(steps, workflowStep{ID: id, Task: task, DependsOn: deps})
}
return steps, nil
}
// validateDependencies checks that every depends_on name refers to a
// real step in the same batch and that the dependency graph has no
// cycle (Kahn's algorithm — the same technique runWorkflow's own
// scheduler uses, run here purely as an up-front check so a cyclic or
// malformed workflow fails loudly as a single clear tool error instead
// of silently deadlocking the scheduler).
func validateDependencies(steps []workflowStep) error {
index := make(map[string]int, len(steps))
for i, s := range steps {
index[s.ID] = i
}
indegree := make([]int, len(steps))
adj := make([][]int, len(steps))
for i, s := range steps {
for _, dep := range s.DependsOn {
j, ok := index[dep]
if !ok {
return fmt.Errorf("dispatch_workflow: step %q depends_on unknown step %q", s.ID, dep)
}
if j == i {
return fmt.Errorf("dispatch_workflow: step %q cannot depend on itself", s.ID)
}
indegree[i]++
adj[j] = append(adj[j], i)
}
}
queue := make([]int, 0, len(steps))
for i, d := range indegree {
if d == 0 {
queue = append(queue, i)
}
}
visited := 0
for len(queue) > 0 {
n := queue[0]
queue = queue[1:]
visited++
for _, m := range adj[n] {
indegree[m]--
if indegree[m] == 0 {
queue = append(queue, m)
}
}
}
if visited != len(steps) {
return fmt.Errorf("dispatch_workflow: depends_on forms a cycle — every step must be reachable without looping back")
}
return nil
}
// ---------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------
// WorkflowVerifier checks whether a step's report actually answers its
// task, returning ok plus a short human-readable reason when it
// doesn't. toolexec.Executor.WorkflowVerifier defaults to
// defaultWorkflowVerifier; a deployment can swap in something richer
// (e.g. one extra, cheap LLM call judging relevance) by setting that
// field — see Executor's doc comment.
type WorkflowVerifier func(task, report string) (ok bool, reason string)
// defaultWorkflowVerifier is a deliberately cheap, deterministic check
// — no second model round-trip per step, consistent with this
// codebase's preference (see pkg/mathkit) for dependency-free,
// inspectable logic wherever a real engine isn't needed. It catches the
// two failure shapes a stalled or confused sub-agent actually produces
// in practice (an empty report, or the canned "finished without
// writing a report" fallback from subagent.go's own dispatchSubagent)
// plus an implausibly short report, without trying to judge the report's
// substance — that judgment call is exactly what's handed back to the
// dispatching model via each step's Verified/Reason, per "real-time
// plan adjustment": the model, not this function, decides what a thin
// report means for the overall task.
func defaultWorkflowVerifier(task, report string) (bool, string) {
r := strings.TrimSpace(report)
if r == "" {
return false, "empty report"
}
if strings.Contains(r, "finished without writing a report") {
return false, "sub-agent produced no findings"
}
if strings.HasPrefix(r, "[step failed to run:") {
return false, "sub-agent run did not complete"
}
const minPlausibleReportChars = 40
if len(r) < minPlausibleReportChars {
return false, "report too short to plausibly answer the task"
}
_ = task // reserved for a future content-aware verifier (e.g. keyword/file-path overlap with the task)
return true, ""
}
// ---------------------------------------------------------------------
// Scheduling + execution
// ---------------------------------------------------------------------
// stepOutcome is one step's final result, after up to two attempts.
type stepOutcome struct {
ID string
Report string
Verified bool
Reason string
Attempts int
}
// maxStepAttempts bounds the "plan adjustment" retry per step: one
// retry, with the verifier's own reason folded into the retry's task
// as a concrete note on what was missing, then accept whatever comes
// back — a workflow step is meant to be a bounded, mechanical unit of
// work, not an open-ended negotiation with the model.
const maxStepAttempts = 2
// runStepWithVerification runs one step (possibly twice) against sub,
// verifying the report each time and amending the task with the
// verifier's reason before a retry.
func runStepWithVerification(ctx context.Context, sub *agentrun.Agent, task string, verify WorkflowVerifier) (report string, attempts int, verified bool, reason string) {
for attempts = 1; attempts <= maxStepAttempts; attempts++ {
raw, _, err := sub.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: task}}, agentrun.Callbacks{})
raw = strings.TrimSpace(raw)
switch {
case raw != "":
report = raw
case err != nil:
report = fmt.Sprintf("[step failed to run: %v]", err)
default:
report = ""
}
ok, why := verify(task, report)
if ok {
return report, attempts, true, ""
}
reason = why
if attempts < maxStepAttempts && ctx.Err() == nil {
task = fmt.Sprintf("%s\n\n[Your previous attempt's report was judged insufficient: %s. Investigate more "+
"thoroughly — check files you may have skipped, and make sure your report cites specific evidence — "+
"then try again.]", task, why)
}
}
return report, attempts, false, reason
}
// runWorkflow executes every step, honoring depends_on and bounded by
// maxParallel concurrent sub-agent runs at any moment — not maxParallel
// per "wave", the whole run: a step becomes eligible the instant its
// last dependency finishes and immediately competes for the same
// semaphore every other ready step is drawing from, so a workflow with
// an uneven dependency shape (a few early steps gating a large fan-out)
// still keeps the configured number of sub-agents busy throughout,
// rather than only during a step's own "wave".
func runWorkflow(ctx context.Context, sub *agentrun.Agent, goal string, steps []workflowStep, maxParallel int, verify WorkflowVerifier) []stepOutcome {
if maxParallel < 1 {
maxParallel = 1
}
n := len(steps)
index := make(map[string]int, n)
for i, s := range steps {
index[s.ID] = i
}
indegree := make([]int, n)
dependents := make([][]int, n)
for i, s := range steps {
for _, dep := range s.DependsOn {
j := index[dep]
indegree[i]++
dependents[j] = append(dependents[j], i)
}
}
outcomes := make([]stepOutcome, n)
sem := make(chan struct{}, maxParallel)
var mu sync.Mutex
var wg sync.WaitGroup
var schedule func(i int)
schedule = func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
mu.Lock()
outcomes[i] = stepOutcome{ID: steps[i].ID, Report: "[skipped: session stopped before this step started]", Attempts: 0}
mu.Unlock()
return
}
task := fmt.Sprintf("Overall workflow goal: %s\n\nYour step (id %q): %s", goal, steps[i].ID, steps[i].Task)
report, attempts, verified, reason := runStepWithVerification(ctx, sub, task, verify)
<-sem
mu.Lock()
outcomes[i] = stepOutcome{ID: steps[i].ID, Report: report, Verified: verified, Reason: reason, Attempts: attempts}
var newlyReady []int
for _, j := range dependents[i] {
indegree[j]--
if indegree[j] == 0 {
newlyReady = append(newlyReady, j)
}
}
mu.Unlock()
for _, j := range newlyReady {
wg.Add(1)
go schedule(j)
}
}
for i := 0; i < n; i++ {
if indegree[i] == 0 {
wg.Add(1)
go schedule(i)
}
}
wg.Wait()
return outcomes
}
// renderWorkflowSummary turns every step's outcome into the single
// string handed back as the dispatch_workflow tool result — the "plan
// adjustment" input the dispatching model actually reads: it sees at a
// glance which steps verified and which didn't, by id, so it can decide
// what to do about the shortfall (re-dispatch, handle manually, or
// proceed if the unverified step turns out not to matter) rather than
// that distinction being silently lost.
func renderWorkflowSummary(goal string, outcomes []stepOutcome) string {
verifiedN := 0
for _, o := range outcomes {
if o.Verified {
verifiedN++
}
}
var b strings.Builder
fmt.Fprintf(&b, "Workflow complete: %d/%d steps verified. Goal: %s\n", verifiedN, len(outcomes), goal)
if verifiedN < len(outcomes) {
fmt.Fprintf(&b, "%d step(s) need attention — see the ⚠️ entries below before treating this workflow as done.\n", len(outcomes)-verifiedN)
}
b.WriteString("\n")
for _, o := range outcomes {
status := "✅ verified"
if !o.Verified {
status = fmt.Sprintf("⚠️ needs attention — %s", o.Reason)
}
fmt.Fprintf(&b, "### Step %q — %s (%d attempt(s))\n%s\n\n", o.ID, status, o.Attempts, o.Report)
}
return strings.TrimSpace(b.String())
}
// ---------------------------------------------------------------------
// The tool call itself
// ---------------------------------------------------------------------
// dispatchWorkflow runs the dispatch_workflow tool call: gated by
// entitlementsFrom(ctx).DynamicWorkflows, bounded by
// Executor.WorkflowMaxParallel and the resolved effort level's own
// MaxParallelSubagents cap (whichever is smaller — see
// agentrun.EffortFromContext), scheduled by runWorkflow, and rendered
// by renderWorkflowSummary.
func (e *Executor) dispatchWorkflow(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
if e.Subagent == nil {
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_workflow: sub-agents are not configured on this deployment", IsError: true}
}
ent := entitlementsFrom(ctx)
if !ent.DynamicWorkflows {
plan := ent.PlanName
if plan == "" {
plan = "your current plan"
}
return vllmkit.ToolResult{
Name: call.Name,
Content: fmt.Sprintf("dispatch_workflow: dynamic workflows aren't available on %s (this feature is part of "+
"the Enterprise/Team/Max tier). Use dispatch_subagent for the same investigative work one step at a "+
"time instead, or do it directly.", plan),
IsError: true,
}
}
goal := strings.TrimSpace(argStr(call.Arguments, "goal"))
if goal == "" {
return vllmkit.ToolResult{Name: call.Name, Content: `dispatch_workflow: "goal" is required`, IsError: true}
}
rawSteps, _ := call.Arguments["steps"].([]any)
steps, err := parseWorkflowSteps(rawSteps)
if err != nil {
return vllmkit.ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
if err := validateDependencies(steps); err != nil {
return vllmkit.ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
maxParallel := e.WorkflowMaxParallel
if maxParallel <= 0 {
maxParallel = defaultWorkflowMaxParallel
}
if levelCap := agentrun.EffortFromContext(ctx).Resolve().MaxParallelSubagents; levelCap > 0 && levelCap < maxParallel {
maxParallel = levelCap
}
if maxParallel > len(steps) {
maxParallel = len(steps)
}
verify := e.WorkflowVerifier
if verify == nil {
verify = defaultWorkflowVerifier
}
outcomes := runWorkflow(ctx, e.Subagent, goal, steps, maxParallel, verify)
return vllmkit.ToolResult{Name: call.Name, Content: renderWorkflowSummary(goal, outcomes)}
}

View File

@@ -0,0 +1,118 @@
package toolexec
import "testing"
func mustParseSteps(t *testing.T, raw []any) []workflowStep {
t.Helper()
steps, err := parseWorkflowSteps(raw)
if err != nil {
t.Fatalf("parseWorkflowSteps: unexpected error: %v", err)
}
return steps
}
func TestParseWorkflowSteps_Basic(t *testing.T) {
raw := []any{
map[string]any{"id": "a", "task": "do a"},
map[string]any{"id": "b", "task": "do b", "depends_on": []any{"a"}},
}
steps := mustParseSteps(t, raw)
if len(steps) != 2 {
t.Fatalf("got %d steps, want 2", len(steps))
}
if steps[1].DependsOn[0] != "a" {
t.Errorf("steps[1].DependsOn = %v, want [a]", steps[1].DependsOn)
}
}
func TestParseWorkflowSteps_Errors(t *testing.T) {
cases := []struct {
name string
raw []any
}{
{"empty", nil},
{"missing id", []any{map[string]any{"task": "x"}}},
{"missing task", []any{map[string]any{"id": "a"}}},
{"duplicate id", []any{
map[string]any{"id": "a", "task": "x"},
map[string]any{"id": "a", "task": "y"},
}},
{"not an object", []any{"not-an-object"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := parseWorkflowSteps(tc.raw); err == nil {
t.Errorf("parseWorkflowSteps(%s): expected an error, got nil", tc.name)
}
})
}
}
func TestParseWorkflowSteps_TooMany(t *testing.T) {
raw := make([]any, maxWorkflowSteps+1)
for i := range raw {
raw[i] = map[string]any{"id": string(rune('a' + i%26)), "task": "x"}
}
if _, err := parseWorkflowSteps(raw); err == nil {
t.Error("expected an error for exceeding maxWorkflowSteps")
}
}
func TestValidateDependencies_OK(t *testing.T) {
steps := mustParseSteps(t, []any{
map[string]any{"id": "a", "task": "x"},
map[string]any{"id": "b", "task": "x", "depends_on": []any{"a"}},
map[string]any{"id": "c", "task": "x", "depends_on": []any{"a", "b"}},
})
if err := validateDependencies(steps); err != nil {
t.Errorf("validateDependencies: unexpected error: %v", err)
}
}
func TestValidateDependencies_UnknownDep(t *testing.T) {
steps := mustParseSteps(t, []any{
map[string]any{"id": "a", "task": "x", "depends_on": []any{"nope"}},
})
if err := validateDependencies(steps); err == nil {
t.Error("expected an error for an unknown dependency")
}
}
func TestValidateDependencies_SelfDependency(t *testing.T) {
steps := mustParseSteps(t, []any{
map[string]any{"id": "a", "task": "x", "depends_on": []any{"a"}},
})
if err := validateDependencies(steps); err == nil {
t.Error("expected an error for a step depending on itself")
}
}
func TestValidateDependencies_Cycle(t *testing.T) {
steps := mustParseSteps(t, []any{
map[string]any{"id": "a", "task": "x", "depends_on": []any{"b"}},
map[string]any{"id": "b", "task": "x", "depends_on": []any{"a"}},
})
if err := validateDependencies(steps); err == nil {
t.Error("expected an error for a two-step cycle")
}
}
func TestDefaultWorkflowVerifier(t *testing.T) {
cases := []struct {
report string
wantOK bool
}{
{"", false},
{" ", false},
{"short", false},
{"(the sub-agent finished without writing a report — try a more specific task description)", false},
{"[step failed to run: context deadline exceeded]", false},
{"This is a plausible, substantial report with real findings in it about the codebase.", true},
}
for _, tc := range cases {
ok, reason := defaultWorkflowVerifier("some task", tc.report)
if ok != tc.wantOK {
t.Errorf("defaultWorkflowVerifier(%q) ok = %v (reason %q), want %v", tc.report, ok, reason, tc.wantOK)
}
}
}

View File

@@ -0,0 +1,58 @@
package toolexec
import (
"fmt"
"strings"
"enkly/pkg/vllmkit"
)
// writeTodos replaces Toolbox.writeTodos verbatim. It was always pure,
// in-memory formatting with no filesystem or database dependency, so
// unlike every other tool it doesn't move to the workspace service —
// there's nothing to execute remotely.
func writeTodos(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
raw, ok := call.Arguments["todos"]
if !ok {
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: missing "todos" array`, IsError: true}
}
list, ok := raw.([]any)
if !ok {
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: "todos" must be an array`, IsError: true}
}
if len(list) == 0 {
return vllmkit.ToolResult{Name: call.Name, Content: "write_todos: the todo list is empty", IsError: true}
}
var sb strings.Builder
inProgress := 0
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: each todo must be an object with "content" and "status"`, IsError: true}
}
content, _ := m["content"].(string)
status, _ := m["status"].(string)
if strings.TrimSpace(content) == "" {
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: a todo is missing "content"`, IsError: true}
}
var box string
switch status {
case "completed":
box = "[x]"
case "in_progress":
box = "[~]"
inProgress++
case "pending", "":
box = "[ ]"
default:
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("write_todos: unknown status %q (want pending, in_progress, or completed)", status), IsError: true}
}
fmt.Fprintf(&sb, "%s %s\n", box, content)
}
if inProgress > 1 {
return vllmkit.ToolResult{Name: call.Name, Content: "write_todos: more than one todo is in_progress — focus on one step at a time", IsError: true}
}
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
}

View File

@@ -0,0 +1,179 @@
// Package grpc adapts application.Service onto the generated
// agentv1.AgentServiceServer interface. StreamTurn's polling/diffing/
// coalescing loop lives here rather than in the application layer
// because it's intrinsically about the generated grpc.ServerStream
// type — see its doc comment for how it maps to the monolith's
// Handlers.StreamChat.
package grpc
import (
"context"
"time"
"google.golang.org/grpc/metadata"
agentv1 "enkly/gen/agent/v1"
"enkly/pkg/grpcerr"
"enkly/pkg/i18n"
"enkly/services/agent/internal/application"
"enkly/services/agent/internal/domain"
)
const (
sseKeepAliveInterval = 15 * time.Second
sseCoalesceDelay = 30 * time.Millisecond
// langMetadataKey mirrors the gateway's handlers.langMetadataKey —
// the gRPC metadata key it sets on StartTurn to tell this service
// which UI locale the visitor has chosen. Kept as a plain string
// constant rather than a shared pkg symbol because it's the one
// and only coupling point between the two services' transport
// layers, and a shared constant for a single string isn't worth a
// new shared pkg import on either side.
langMetadataKey = "x-enkly-lang"
)
// langFromIncomingContext reads the visitor's chosen locale off ctx's
// incoming gRPC metadata (set by the gateway — see langMetadataKey's
// doc comment), defaulting to i18n.Default when the call carries none
// (any caller other than the gateway, or an older gateway build).
func langFromIncomingContext(ctx context.Context) i18n.Locale {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return i18n.Default
}
values := md.Get(langMetadataKey)
if len(values) == 0 {
return i18n.Default
}
return i18n.ParseLocale(values[0])
}
type Server struct {
agentv1.UnimplementedAgentServiceServer
Svc *application.Service
}
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
func toProtoOutcome(o application.Outcome) agentv1.StartTurnOutcome {
switch o {
case application.OutcomeOK:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_OK
case application.OutcomeConversationNotFound:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_NOT_FOUND
case application.OutcomeConversationBusy:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_BUSY
case application.OutcomeTooManyStreams:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_TOO_MANY_STREAMS
case application.OutcomeRateLimited:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_RATE_LIMITED
case application.OutcomeQuotaExceeded:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED
default:
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_INTERNAL_ERROR
}
}
func (s *Server) StartTurn(ctx context.Context, req *agentv1.StartTurnRequest) (*agentv1.StartTurnResponse, error) {
lang := langFromIncomingContext(ctx)
res := s.Svc.StartTurn(ctx, req.GetUserId(), req.GetConversationId(), req.GetPrompt(), lang)
return &agentv1.StartTurnResponse{Outcome: toProtoOutcome(res.Outcome), Message: res.Message, StreamId: res.StreamID}, nil
}
func toProtoToolCall(tc domain.ToolCallResult) *agentv1.ToolCall {
return &agentv1.ToolCall{Name: tc.Name, ArgumentsJson: tc.ArgumentsJSON, Result: tc.Result, IsError: tc.IsError}
}
func toProtoThinkingRound(tr domain.ThinkingRound) *agentv1.ThinkingRound {
return &agentv1.ThinkingRound{Round: int32(tr.Round), Thinking: tr.Thinking}
}
// StreamTurn replaces Handlers.StreamChat, translated from SSE to
// server-streaming gRPC: same snapshot/diff/coalesce loop, same
// Last-Event-ID-style resume (resume_from_tool_index), same keepalive
// interval — just agentv1.TurnEvent messages instead of SSE frames. The
// gateway's own SSE handler reads this stream and does the reverse
// translation back to SSE for the browser, plus all HTML rendering
// (this service deals in plain text only).
func (s *Server) StreamTurn(req *agentv1.StreamTurnRequest, stream agentv1.AgentService_StreamTurnServer) error {
st := s.Svc.Streams.Get(req.GetStreamId())
if st == nil || st.UserID != req.GetUserId() {
return grpcerr.NotFound("stream")
}
sentTools := int(req.GetResumeFromToolIndex())
// sentThinkRounds has no resume-from-request counterpart the way
// sentTools does (StreamTurnRequest carries no thinking-round
// cursor) — a reconnect just resends every round from the start,
// same as lastThinking resetting to "" below and re-sending the
// full cumulative trace. Cheap and harmless: a run has at most
// MaxRounds (12) of these, each a short text, never the
// unboundedly-large payloads tool results can be.
sentThinkRounds := 0
var lastToken, lastThinking string
ctx := stream.Context()
for {
snap, changed := st.Snapshot()
for i := sentTools; i < len(snap.Tools); i++ {
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ToolCall{ToolCall: toProtoToolCall(snap.Tools[i])}}); err != nil {
return err
}
}
if len(snap.Tools) > sentTools {
sentTools = len(snap.Tools)
}
for i := sentThinkRounds; i < len(snap.ThinkingRounds); i++ {
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingRound{ThinkingRound: toProtoThinkingRound(snap.ThinkingRounds[i])}}); err != nil {
return err
}
}
if len(snap.ThinkingRounds) > sentThinkRounds {
sentThinkRounds = len(snap.ThinkingRounds)
}
if snap.ThinkText != lastThinking {
lastThinking = snap.ThinkText
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingDelta{ThinkingDelta: lastThinking}}); err != nil {
return err
}
}
if snap.Done {
done := &agentv1.TurnDone{FinalText: snap.FinalText}
for _, tc := range snap.FinalToolCalls {
done.ToolCalls = append(done.ToolCalls, toProtoToolCall(tc))
}
return stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_Done{Done: done}})
}
if snap.TokenText != lastToken {
lastToken = snap.TokenText
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_TokenDelta{TokenDelta: lastToken}}); err != nil {
return err
}
}
select {
case <-changed:
// Let a burst of tokens accumulate so we send one event, not fifty.
select {
case <-time.After(sseCoalesceDelay):
case <-ctx.Done():
return nil
}
case <-time.After(sseKeepAliveInterval):
// Server-streaming gRPC has its own keepalive at the
// transport level (HTTP/2 PINGs); nothing to send here.
// The timer's only job is to re-loop and re-check ctx.Done.
case <-ctx.Done():
return nil
}
}
}
func (s *Server) StopTurn(ctx context.Context, req *agentv1.StopTurnRequest) (*agentv1.StopTurnResponse, error) {
if !s.Svc.StopTurn(req.GetUserId(), req.GetStreamId()) {
return nil, grpcerr.NotFound("stream")
}
return &agentv1.StopTurnResponse{}, nil
}

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/billing/ services/billing/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/billing-server ./services/billing/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/billing-server ./billing-server
COPY --from=build /src/services/billing/migrations ./migrations
ENV ENKLY_BILLING_GRPC_ADDR=:9002
EXPOSE 9002
ENTRYPOINT ["./billing-server"]

View File

@@ -0,0 +1,109 @@
// Command server runs the billing microservice: a gRPC server exposing
// api/proto/billing/v1, backed by its own Postgres database and the
// Stripe REST API. Replaces internal/handlers/billing.go +
// internal/billing/*.go.
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"
billingv1 "enkly/gen/billing/v1"
"enkly/pkg/envkit"
"enkly/pkg/eventbus"
"enkly/pkg/pgkit"
"enkly/services/billing/internal/application"
"enkly/services/billing/internal/infrastructure/postgres"
"enkly/services/billing/internal/infrastructure/stripeapi"
grpcsrv "enkly/services/billing/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_BILLING_GRPC_ADDR", ":9002")
postgresDSN = envkit.String("ENKLY_BILLING_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_billing?sslmode=disable")
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
migrationsPath = envkit.String("ENKLY_BILLING_MIGRATIONS", "migrations/0001_init.sql")
stripeSecretKey = envkit.String("STRIPE_SECRET_KEY", "")
stripePublishKey = envkit.String("STRIPE_PUBLISH_KEY", "")
stripeWebhookKey = envkit.String("STRIPE_WEBHOOK_SECRET", "")
)
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{
Subscriptions: postgres.NewSubscriptionRepo(db),
StripeEvents: postgres.NewStripeEventRepo(db),
Usage: postgres.NewUsageRepo(db),
Stripe: stripeapi.New(stripeSecretKey),
WebhookSecret: stripeWebhookKey,
Events: bus,
Logger: logger,
GetEnv: os.Getenv,
}
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
billingv1.RegisterBillingServiceServer(grpcServer, grpcsrv.New(svc, stripePublishKey))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("billing service listening", "addr", grpcAddr, "stripe_configured", stripeSecretKey != "")
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,320 @@
// Package application holds billing's use cases, transplanted from
// internal/handlers/billing.go with the html/template "planView"
// rendering left out — that's the gateway's job now (it calls
// GetPlanCatalog + GetOrCreateSubscription and builds the page itself),
// since presentation never belongs in a bounded context's core.
package application
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"sync"
"time"
"enkly/pkg/eventbus"
"enkly/pkg/eventbus/events"
"enkly/services/billing/internal/domain"
"enkly/services/billing/internal/infrastructure/stripeapi"
)
type Publisher interface {
PublishBestEffort(ctx context.Context, routingKey string, payload any)
}
type Service struct {
Subscriptions domain.SubscriptionRepository
StripeEvents domain.StripeEventRepository
Usage domain.UsageRepository
Stripe *stripeapi.Client
WebhookSecret string
Events Publisher
Logger *slog.Logger
// GetPriceEnvVar resolves a plan's configured Stripe Price ID env
// var at startup time (see cmd/server/main.go) — injected rather
// than read from os.Getenv directly, same reasoning as the
// monolith's billing.PriceIDs(getenv).
GetEnv func(key string) string
priceMu sync.Mutex
planPrices map[domain.Plan]string
stripePrices map[string]domain.Plan
}
// GetOrCreateSubscription replaces db.GetOrCreateSubscription.
func (s *Service) GetOrCreateSubscription(ctx context.Context, userID int64) (*domain.Subscription, error) {
return s.Subscriptions.GetOrCreate(ctx, userID)
}
// PlanCatalogEntry is GetPlanCatalog's per-plan result.
type PlanCatalogEntry struct {
domain.PlanInfo
Available bool
}
// GetPlanCatalog replaces the plan-availability half of
// buildSubscriptionData: Free is always available, every paid plan is
// available whenever Stripe itself is configured (its Price is created
// on demand — see planPrice/EnsurePrice).
func (s *Service) GetPlanCatalog(ctx context.Context) []PlanCatalogEntry {
out := make([]PlanCatalogEntry, 0, len(domain.Catalog))
for _, p := range domain.Catalog {
out = append(out, PlanCatalogEntry{
PlanInfo: p,
Available: p.Plan == domain.PlanFree || s.Stripe.Configured(),
})
}
return out
}
// CreateCheckoutSession replaces Handlers.CreateCheckout (minus the
// "plan==free redirects to the portal" edge case, which the gateway
// handles at the edge since it's pure routing, not a billing decision).
func (s *Service) CreateCheckoutSession(ctx context.Context, userID int64, email string, plan domain.Plan, successURL, cancelURL string) (string, error) {
if !s.Stripe.Configured() {
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
}
priceID, err := s.planPrice(ctx, plan)
if err != nil {
return "", err
}
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
if err != nil {
return "", err
}
_, checkoutURL, err := s.Stripe.CreateCheckoutSession(ctx, stripeapi.CheckoutParams{
PriceID: priceID,
CustomerID: sub.StripeCustomerID,
CustomerEmail: email,
ClientReferenceID: strconv.FormatInt(userID, 10),
UserID: userID,
SuccessURL: successURL,
CancelURL: cancelURL,
})
return checkoutURL, err
}
// SyncCheckoutSession replaces Handlers.syncCheckoutSession.
func (s *Service) SyncCheckoutSession(ctx context.Context, userID int64, sessionID string) (*domain.Subscription, error) {
sess, err := s.Stripe.GetCheckoutSession(ctx, sessionID)
if err != nil {
return nil, err
}
if sess.ClientReferenceID != strconv.FormatInt(userID, 10) {
return nil, fmt.Errorf("session %s does not belong to user %d", sess.ID, userID)
}
if sess.Subscription == "" || sess.Customer == "" {
return nil, fmt.Errorf("session %s has no subscription yet", sess.ID)
}
stripeSub, err := s.Stripe.GetSubscription(ctx, sess.Subscription)
if err != nil {
return nil, err
}
plan, ok := s.planForPrice(ctx, stripeSub.PriceID())
if !ok {
return nil, fmt.Errorf("subscription %s: unrecognized price %q", stripeSub.ID, stripeSub.PriceID())
}
var periodEnd time.Time
if unix := stripeSub.PeriodEndUnix(); unix > 0 {
periodEnd = time.Unix(unix, 0)
}
if err := s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, sess.Customer, stripeSub.ID, stripeSub.PriceID(),
string(plan), stripeSub.Status, periodEnd, stripeSub.CancelAtPeriodEnd); err != nil {
return nil, err
}
s.publishChanged(ctx, userID, plan, stripeSub.Status, "checkout.session.completed")
return s.Subscriptions.GetOrCreate(ctx, userID)
}
// CreateBillingPortalSession replaces Handlers.BillingPortal.
func (s *Service) CreateBillingPortalSession(ctx context.Context, userID int64, returnURL string) (string, error) {
if !s.Stripe.Configured() {
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
}
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
if err != nil {
return "", err
}
if sub.StripeCustomerID == "" {
return "", errNoBillingAccount
}
return s.Stripe.CreateBillingPortalSession(ctx, sub.StripeCustomerID, returnURL)
}
var errNoBillingAccount = errors.New("billing: no stripe customer on file yet")
// IsNoBillingAccount reports whether err is the "subscribe to a paid
// plan first" case Handlers.BillingPortal used to check for via a bare
// StripeCustomerID == "" comparison.
func IsNoBillingAccount(err error) bool { return errors.Is(err, errNoBillingAccount) }
// CheckAndIncrementUsage replaces the daily-cap check inlined in
// Handlers.Chat, now its own use case since the agent service calls it
// once per turn over gRPC.
func (s *Service) CheckAndIncrementUsage(ctx context.Context, userID int64) (allowed bool, used, limit int, planName string, err error) {
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
if err != nil {
return false, 0, 0, "", err
}
info := domain.Get(sub.Plan)
if info.DailyMessageLimit == 0 {
// Unlimited plan: still counted (for the usage page), never capped.
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
return true, n, 0, info.Name, err
}
used, err = s.Usage.DailyUsage(ctx, userID)
if err != nil {
return false, 0, info.DailyMessageLimit, info.Name, err
}
if used >= info.DailyMessageLimit {
return false, used, info.DailyMessageLimit, info.Name, nil
}
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
return true, n, info.DailyMessageLimit, info.Name, err
}
// GetDailyUsage replaces db.DailyUsage: a read-only lookup for the
// /subscription page, which must not itself count as a sent message.
func (s *Service) GetDailyUsage(ctx context.Context, userID int64) (int, error) {
return s.Usage.DailyUsage(ctx, userID)
}
func (s *Service) publishChanged(ctx context.Context, userID int64, plan domain.Plan, status, eventType string) {
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeySubscriptionChanged, events.SubscriptionChanged{
UserID: userID, Plan: string(plan), Status: status, EventType: eventType,
})
}
// ---------------------------------------------------------------------
// Price resolution cache — replaces Handlers.planPrice / planForPrice.
// ---------------------------------------------------------------------
func (s *Service) planPrice(ctx context.Context, plan domain.Plan) (string, error) {
s.priceMu.Lock()
defer s.priceMu.Unlock()
if id, ok := s.planPrices[plan]; ok && id != "" {
return id, nil
}
id, err := s.Stripe.EnsurePrice(ctx, stripeapi.Info{
Plan: string(domain.Get(string(plan)).Plan), Name: domain.Get(string(plan)).Name, PriceEUR: domain.Get(string(plan)).PriceEUR,
})
if err != nil {
return "", err
}
if s.planPrices == nil {
s.planPrices = map[domain.Plan]string{}
}
if s.stripePrices == nil {
s.stripePrices = map[string]domain.Plan{}
}
s.planPrices[plan] = id
s.stripePrices[id] = plan
return id, nil
}
func (s *Service) planForPrice(ctx context.Context, priceID string) (domain.Plan, bool) {
s.priceMu.Lock()
plan, ok := s.stripePrices[priceID]
s.priceMu.Unlock()
if ok || !s.Stripe.Configured() {
return plan, ok
}
for _, p := range domain.Catalog {
if p.PriceEnvVar == "" {
continue
}
if _, err := s.planPrice(ctx, p.Plan); err != nil {
s.Logger.Warn("resolve stripe price", "plan", p.Name, "err", err)
}
}
s.priceMu.Lock()
defer s.priceMu.Unlock()
plan, ok = s.stripePrices[priceID]
return plan, ok
}
// ---------------------------------------------------------------------
// Stripe webhook ingestion — replaces Handlers.StripeWebhook / applyStripeEvent.
// ---------------------------------------------------------------------
func (s *Service) IngestStripeWebhook(ctx context.Context, payload []byte, sigHeader string) error {
ev, err := stripeapi.ConstructEvent(payload, sigHeader, s.WebhookSecret)
if err != nil {
return fmt.Errorf("stripe webhook rejected: %w", err)
}
firstTime, err := s.StripeEvents.RecordIfNew(ctx, ev.ID, ev.Type)
if err != nil {
return fmt.Errorf("record stripe event: %w", err)
}
if !firstTime {
return nil // already applied on an earlier delivery
}
if err := s.applyStripeEvent(ctx, ev); err != nil {
// Logged, not propagated: the event is already recorded as
// seen, so returning an error here would just make the
// gateway tell Stripe to retry a delivery that (from Stripe's
// side) already succeeded — see the monolith's identical
// comment in StripeWebhook.
s.Logger.Error("apply stripe event", "err", err, "type", ev.Type, "event_id", ev.ID)
}
return nil
}
func (s *Service) applyStripeEvent(ctx context.Context, ev *stripeapi.Event) error {
switch ev.Type {
case "checkout.session.completed":
var sess stripeapi.CheckoutSession
if err := json.Unmarshal(ev.Data.Object, &sess); err != nil {
return fmt.Errorf("decode checkout session: %w", err)
}
if sess.Customer == "" {
return nil
}
userID, err := strconv.ParseInt(sess.ClientReferenceID, 10, 64)
if err != nil {
return fmt.Errorf("checkout session %s: unusable client_reference_id %q", sess.ID, sess.ClientReferenceID)
}
return s.Subscriptions.SetStripeCustomer(ctx, userID, sess.Customer)
case "customer.subscription.created", "customer.subscription.updated":
var st stripeapi.StripeSubscription
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
plan, ok := s.planForPrice(ctx, st.PriceID())
if !ok {
return fmt.Errorf("subscription %s: unrecognized price %q (check the STRIPE_PRICE_* env vars)", st.ID, st.PriceID())
}
var periodEnd time.Time
if unix := st.PeriodEndUnix(); unix > 0 {
periodEnd = time.Unix(unix, 0)
}
err := s.Subscriptions.ApplyStripeSubscription(ctx, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
if errors.Is(err, domain.ErrNotFound) {
userID, perr := strconv.ParseInt(st.Metadata["user_id"], 10, 64)
if perr != nil {
return fmt.Errorf("subscription %s: no matching customer row and no usable metadata.user_id", st.ID)
}
err = s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
if err == nil {
s.publishChanged(ctx, userID, plan, st.Status, ev.Type)
}
return err
}
return err
case "customer.subscription.deleted":
var st stripeapi.StripeSubscription
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
return s.Subscriptions.CancelByStripeSubscriptionID(ctx, st.ID)
default:
return nil
}
}

View File

@@ -0,0 +1,112 @@
// Package domain is the Billing bounded context's entities, plan
// catalog and repository interfaces — transplanted from
// internal/billing/plan.go (pure data, unchanged) plus the shape of
// internal/db's subscriptions.go results.
package domain
import (
"context"
"errors"
"strings"
"time"
)
var ErrNotFound = errors.New("billing: not found")
// Plan identifies one of enkly's subscription tiers. Stored verbatim in
// subscriptions.plan — see migrations/0001_init.sql's CHECK constraint.
type Plan string
const (
PlanFree Plan = "free"
PlanBasic Plan = "basic"
PlanPro Plan = "pro"
PlanPremium Plan = "premium"
)
// PlanInfo describes one plan: its display name/price and the env var
// holding its Stripe Price ID (empty for Free).
type PlanInfo struct {
Plan Plan
Name string
PriceEUR int
PriceEnvVar string
DailyMessageLimit int // 0 = unlimited
}
// Catalog lists every plan, in display order — identical to the
// monolith's billing.Catalog.
var Catalog = []PlanInfo{
{Plan: PlanFree, Name: "Free", PriceEUR: 0, PriceEnvVar: "", DailyMessageLimit: 20},
{Plan: PlanBasic, Name: "Basic", PriceEUR: 5, PriceEnvVar: "STRIPE_PRICE_BASIC", DailyMessageLimit: 100},
{Plan: PlanPro, Name: "Pro", PriceEUR: 8, PriceEnvVar: "STRIPE_PRICE_PRO", DailyMessageLimit: 400},
{Plan: PlanPremium, Name: "Premium", PriceEUR: 12, PriceEnvVar: "STRIPE_PRICE_PREMIUM", DailyMessageLimit: 0},
}
func Get(plan string) PlanInfo {
for _, p := range Catalog {
if string(p.Plan) == plan {
return p
}
}
return Catalog[0]
}
func Valid(plan string) bool {
for _, p := range Catalog {
if string(p.Plan) == plan {
return true
}
}
return false
}
func IsPriceID(v string) bool { return strings.HasPrefix(v, "price_") && len(v) > len("price_") }
// Rank returns plan's position in the catalog, so an upgrade can be
// told from a downgrade. Unknown plans rank as Free.
func Rank(plan Plan) int {
for i, p := range Catalog {
if p.Plan == plan {
return i
}
}
return 0
}
// Subscription is one user's billing state — models.Subscription minus
// json tags.
type Subscription struct {
UserID int64
Plan string
Status string
StripeCustomerID string
StripeSubscriptionID string
StripePriceID string
CurrentPeriodEnd time.Time
CancelAtPeriodEnd bool
CreatedAt time.Time
UpdatedAt time.Time
}
// SubscriptionRepository persists subscriptions, mirroring
// internal/db/subscriptions.go's functions one-for-one.
type SubscriptionRepository interface {
GetOrCreate(ctx context.Context, userID int64) (*Subscription, error)
SetStripeCustomer(ctx context.Context, userID int64, customerID string) error
ApplyStripeSubscription(ctx context.Context, customerID, subscriptionID, priceID, plan, status string, periodEnd time.Time, cancelAtPeriodEnd bool) error
ApplyStripeSubscriptionForUser(ctx context.Context, userID int64, customerID, subscriptionID, priceID, plan, status string, periodEnd time.Time, cancelAtPeriodEnd bool) error
CancelByStripeSubscriptionID(ctx context.Context, subscriptionID string) error
}
// StripeEventRepository is the webhook idempotency log.
type StripeEventRepository interface {
// RecordIfNew reports whether eventID hadn't been seen before.
RecordIfNew(ctx context.Context, eventID, eventType string) (firstTime bool, err error)
}
// UsageRepository is the per-user, per-UTC-day message counter.
type UsageRepository interface {
DailyUsage(ctx context.Context, userID int64) (int, error)
IncrementDailyUsage(ctx context.Context, userID int64) (int, error)
}

View File

@@ -0,0 +1,154 @@
// Package postgres implements billing's domain repositories against the
// billing database (see services/billing/migrations/0001_init.sql).
// One-for-one transplant of internal/db/subscriptions.go.
package postgres
import (
"context"
"database/sql"
"errors"
"time"
"enkly/pkg/pgkit"
"enkly/services/billing/internal/domain"
)
const subscriptionColumns = `user_id, plan, status, coalesce(stripe_customer_id, ''), coalesce(stripe_subscription_id, ''), ` +
`coalesce(stripe_price_id, ''), current_period_end, cancel_at_period_end, created_at, updated_at`
func scanSubscription(row interface{ Scan(...any) error }) (*domain.Subscription, error) {
var (
s domain.Subscription
periodEnd sql.NullTime
)
if err := row.Scan(&s.UserID, &s.Plan, &s.Status, &s.StripeCustomerID, &s.StripeSubscriptionID,
&s.StripePriceID, &periodEnd, &s.CancelAtPeriodEnd, &s.CreatedAt, &s.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
if periodEnd.Valid {
s.CurrentPeriodEnd = periodEnd.Time
}
return &s, nil
}
type SubscriptionRepo struct {
DB *pgkit.DB
}
func NewSubscriptionRepo(db *pgkit.DB) *SubscriptionRepo { return &SubscriptionRepo{DB: db} }
func (r *SubscriptionRepo) GetOrCreate(ctx context.Context, userID int64) (*domain.Subscription, error) {
if _, err := r.DB.ExecContext(ctx,
`INSERT INTO subscriptions (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, userID); err != nil {
return nil, err
}
return scanSubscription(r.DB.QueryRowContext(ctx,
`SELECT `+subscriptionColumns+` FROM subscriptions WHERE user_id = $1`, userID))
}
func (r *SubscriptionRepo) SetStripeCustomer(ctx context.Context, userID int64, customerID string) error {
_, err := r.DB.ExecContext(ctx,
`UPDATE subscriptions SET stripe_customer_id = $2, updated_at = now() WHERE user_id = $1`,
userID, customerID)
return err
}
func (r *SubscriptionRepo) ApplyStripeSubscription(ctx context.Context, customerID, subscriptionID, priceID, plan, status string,
periodEnd time.Time, cancelAtPeriodEnd bool) error {
var pe any
if !periodEnd.IsZero() {
pe = periodEnd
}
res, err := r.DB.ExecContext(ctx, `
UPDATE subscriptions SET
plan = $2, status = $3, stripe_subscription_id = $4, stripe_price_id = $5,
current_period_end = $6, cancel_at_period_end = $7, updated_at = now()
WHERE stripe_customer_id = $1`,
customerID, plan, status, subscriptionID, priceID, pe, cancelAtPeriodEnd)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return domain.ErrNotFound
}
return nil
}
func (r *SubscriptionRepo) ApplyStripeSubscriptionForUser(ctx context.Context, userID int64, customerID, subscriptionID, priceID, plan, status string,
periodEnd time.Time, cancelAtPeriodEnd bool) error {
var pe any
if !periodEnd.IsZero() {
pe = periodEnd
}
_, err := r.DB.ExecContext(ctx, `
INSERT INTO subscriptions (user_id, plan, status, stripe_customer_id, stripe_subscription_id, stripe_price_id, current_period_end, cancel_at_period_end)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id) DO UPDATE SET
plan = excluded.plan, status = excluded.status, stripe_customer_id = excluded.stripe_customer_id,
stripe_subscription_id = excluded.stripe_subscription_id, stripe_price_id = excluded.stripe_price_id,
current_period_end = excluded.current_period_end, cancel_at_period_end = excluded.cancel_at_period_end,
updated_at = now()`,
userID, plan, status, customerID, subscriptionID, priceID, pe, cancelAtPeriodEnd)
return err
}
func (r *SubscriptionRepo) CancelByStripeSubscriptionID(ctx context.Context, subscriptionID string) error {
_, err := r.DB.ExecContext(ctx, `
UPDATE subscriptions SET
plan = 'free', status = 'canceled', stripe_subscription_id = NULL,
stripe_price_id = NULL, cancel_at_period_end = false, updated_at = now()
WHERE stripe_subscription_id = $1`, subscriptionID)
return err
}
// ---------------------------------------------------------------------
// Webhook idempotency
// ---------------------------------------------------------------------
type StripeEventRepo struct {
DB *pgkit.DB
}
func NewStripeEventRepo(db *pgkit.DB) *StripeEventRepo { return &StripeEventRepo{DB: db} }
func (r *StripeEventRepo) RecordIfNew(ctx context.Context, eventID, eventType string) (bool, error) {
res, err := r.DB.ExecContext(ctx,
`INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, eventID, eventType)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// ---------------------------------------------------------------------
// Daily usage
// ---------------------------------------------------------------------
type UsageRepo struct {
DB *pgkit.DB
}
func NewUsageRepo(db *pgkit.DB) *UsageRepo { return &UsageRepo{DB: db} }
func (r *UsageRepo) DailyUsage(ctx context.Context, userID int64) (int, error) {
var n int
err := r.DB.QueryRowContext(ctx,
`SELECT messages FROM usage_daily WHERE user_id = $1 AND day = current_date`, userID).Scan(&n)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return n, err
}
func (r *UsageRepo) IncrementDailyUsage(ctx context.Context, userID int64) (int, error) {
var n int
err := r.DB.QueryRowContext(ctx, `
INSERT INTO usage_daily (user_id, day, messages) VALUES ($1, current_date, 1)
ON CONFLICT (user_id, day) DO UPDATE SET messages = usage_daily.messages + 1
RETURNING messages`, userID).Scan(&n)
return n, err
}

View File

@@ -0,0 +1,468 @@
package stripeapi
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// apiBase is Stripe's REST API root. Every call in this file talks
// directly to it over net/http — enkly deliberately has no dependency
// on Stripe's official Go SDK (see the comment on config/env.go about
// keeping go.mod small), and the handful of endpoints subscriptions
// need (Checkout Sessions, Billing Portal Sessions, webhook signature
// verification) are simple enough not to need it.
const apiBase = "https://api.stripe.com/v1"
// Info is the subset of domain.PlanInfo EnsurePrice needs to find-or-
// create a plan's monthly price in Stripe. Kept as its own small type
// here (rather than importing services/billing/internal/domain) so
// this package stays a standalone Stripe API client with zero
// dependency on billing's own domain model — the same separation the
// monolith had between internal/billing (which owned both) and nothing
// else, now made explicit by the package boundary.
type Info struct {
Plan string
Name string
PriceEUR int
}
// Client is a small Stripe API client, authenticated with a secret key
// (STRIPE_SECRET_KEY). Safe for concurrent use.
type Client struct {
SecretKey string
HTTPClient *http.Client
}
// New builds a Client. secretKey is Stripe's secret key (sk_test_… or
// sk_live_…) — never the publishable key, and never exposed to the
// browser.
func New(secretKey string) *Client {
return &Client{
SecretKey: secretKey,
HTTPClient: &http.Client{Timeout: 15 * time.Second},
}
}
// Configured reports whether a secret key was supplied, so callers can
// give a clear error instead of a confusing Stripe 401 when it wasn't.
func (c *Client) Configured() bool { return c != nil && c.SecretKey != "" }
// post sends a application/x-www-form-urlencoded POST to a Stripe
// endpoint (Stripe's API accepts form-encoded bodies for every
// resource, including nested params via PHP-style bracket keys — see
// the callers below) and decodes the JSON response into out.
func (c *Client) post(ctx context.Context, path string, form url.Values, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+path, strings.NewReader(form.Encode()))
if err != nil {
return err
}
return c.do(req, out)
}
func (c *Client) get(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil)
if err != nil {
return err
}
return c.do(req, out)
}
// APIError is what do() returns for any non-2xx response from Stripe —
// kept as its own type (rather than just fmt.Errorf-ing the message in)
// specifically so callers showing this to a self-hosting admin (see
// internal/handlers/billing.go's subscriptionError) can display
// Message on its own: it's Stripe's own human-readable explanation
// (e.g. "No such price: 'prod_ABC123'; a similar object exists in live
// mode, but a test mode key was used to make this request.") and is
// almost always more useful for fixing a misconfigured Price ID, a
// test/live key mismatch, etc. than a generic "something went wrong".
type APIError struct {
Path string // the Stripe API path that was called, e.g. "/checkout/sessions"
Status int
Type string // Stripe's error "type", e.g. "invalid_request_error"
Message string // Stripe's own explanation — safe and useful to show as-is
}
func (e *APIError) Error() string {
if e.Message != "" {
return fmt.Sprintf("stripe %s: %s", e.Path, e.Message)
}
return fmt.Sprintf("stripe %s: status %d", e.Path, e.Status)
}
// Explain extracts a clean, safe-to-display message from an error one
// of this package's Client methods returned: Stripe's own explanation
// for an *APIError (a bad Price ID, a test/live key mismatch, an
// invalid API key, ...), or the error's own message otherwise (a
// network/DNS failure reaching Stripe, a malformed local response).
// Never includes the secret key — it's only ever sent in a request
// header, never echoed back into an error — so this is safe to show
// directly to whoever administers this server.
func Explain(err error) string {
if err == nil {
return ""
}
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Message != "" {
return apiErr.Message
}
return err.Error()
}
func (c *Client) do(req *http.Request, out any) error {
if c.SecretKey == "" {
return errors.New("billing: STRIPE_SECRET_KEY is not configured")
}
req.SetBasicAuth(c.SecretKey, "") // Stripe auth: secret key as the HTTP Basic username, empty password
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Pin an API version so Stripe's response shape (e.g. where
// current_period_end lives on a subscription) can't drift under us
// between requests. Any recent version works; this one is well
// after Price/nested line_items became standard.
req.Header.Set("Stripe-Version", "2024-06-20")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("billing: request %s: %w", req.URL.Path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("billing: read response: %w", err)
}
if resp.StatusCode >= 300 {
var parsed struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
_ = json.Unmarshal(body, &parsed)
return &APIError{Path: req.URL.Path, Status: resp.StatusCode, Type: parsed.Error.Type, Message: parsed.Error.Message}
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("billing: decode response: %w", err)
}
return nil
}
// ---------------------------------------------------------------------
// Customers
// ---------------------------------------------------------------------
// CreateCustomer creates a Stripe Customer for a newly-paying user.
// userID is stashed in metadata so a webhook can map a Stripe object
// back to an enkly account even if the subscriptions row lookup by
// stripe_customer_id ever misses.
func (c *Client) CreateCustomer(ctx context.Context, email string, userID int64) (string, error) {
form := url.Values{
"email": {email},
"metadata[user_id]": {strconv.FormatInt(userID, 10)},
}
var out struct {
ID string `json:"id"`
}
if err := c.post(ctx, "/customers", form, &out); err != nil {
return "", err
}
return out.ID, nil
}
// ---------------------------------------------------------------------
// Prices
// ---------------------------------------------------------------------
// EnsurePrice returns the Stripe Price ID for a paid plan, creating the
// product and recurring monthly EUR price on first use. Prices are found
// again on later runs through a Stripe "lookup key" (enkly_<plan>_monthly),
// so calling this on every startup never creates duplicates. This is what
// lets the app work when STRIPE_PRICE_* is unset or wrongly set to an
// amount like "5" instead of a price_… ID.
func (c *Client) EnsurePrice(ctx context.Context, info Info) (string, error) {
lookupKey := "enkly_" + string(info.Plan) + "_monthly"
var found struct {
Data []struct {
ID string `json:"id"`
UnitAmount int `json:"unit_amount"`
Currency string `json:"currency"`
} `json:"data"`
}
q := url.Values{"lookup_keys[]": {lookupKey}, "active": {"true"}}
if err := c.get(ctx, "/prices?"+q.Encode(), &found); err != nil {
return "", err
}
for _, pr := range found.Data {
if pr.UnitAmount == info.PriceEUR*100 && pr.Currency == "eur" {
return pr.ID, nil
}
}
var product struct {
ID string `json:"id"`
}
if err := c.post(ctx, "/products", url.Values{"name": {"enkly " + info.Name}}, &product); err != nil {
return "", err
}
var price struct {
ID string `json:"id"`
}
err := c.post(ctx, "/prices", url.Values{
"product": {product.ID},
"currency": {"eur"},
"unit_amount": {strconv.Itoa(info.PriceEUR * 100)},
"recurring[interval]": {"month"},
"nickname": {"enkly " + info.Name + " (monthly)"},
"lookup_key": {lookupKey},
"transfer_lookup_key": {"true"},
}, &price)
if err != nil {
return "", err
}
return price.ID, nil
}
// ---------------------------------------------------------------------
// Checkout Sessions
// ---------------------------------------------------------------------
// CheckoutParams describes one subscription checkout.
type CheckoutParams struct {
PriceID string
CustomerID string // existing Stripe customer, if any (preferred over CustomerEmail)
CustomerEmail string // used only when CustomerID is empty
ClientReferenceID string // enkly user ID, as a string — echoed back on the session object
UserID int64
SuccessURL string
CancelURL string
}
// CreateCheckoutSession starts a Stripe-hosted Checkout page for a new
// subscription. The caller redirects the browser to the returned URL;
// enkly never collects card details itself.
func (c *Client) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (sessionID, checkoutURL string, err error) {
form := url.Values{
"mode": {"subscription"},
"success_url": {p.SuccessURL},
"cancel_url": {p.CancelURL},
"line_items[0][price]": {p.PriceID},
"line_items[0][quantity]": {"1"},
"client_reference_id": {p.ClientReferenceID},
"subscription_data[metadata][user_id]": {strconv.FormatInt(p.UserID, 10)},
"allow_promotion_codes": {"true"},
"billing_address_collection": {"auto"},
}
if p.CustomerID != "" {
form.Set("customer", p.CustomerID)
} else if p.CustomerEmail != "" {
form.Set("customer_email", p.CustomerEmail)
}
var out struct {
ID string `json:"id"`
URL string `json:"url"`
}
if err := c.post(ctx, "/checkout/sessions", form, &out); err != nil {
return "", "", err
}
return out.ID, out.URL, nil
}
// CheckoutSession is the subset of a Checkout Session object handlers
// need after a "checkout.session.completed" webhook.
type CheckoutSession struct {
ID string `json:"id"`
Customer string `json:"customer"`
Subscription string `json:"subscription"`
ClientReferenceID string `json:"client_reference_id"`
Metadata map[string]string `json:"metadata"`
}
// GetCheckoutSession retrieves a Checkout Session by ID. The success page
// uses it to confirm a payment with Stripe directly, so a plan activates
// even when webhooks aren't reaching the server (e.g. local testing).
func (c *Client) GetCheckoutSession(ctx context.Context, id string) (*CheckoutSession, error) {
var out CheckoutSession
if err := c.get(ctx, "/checkout/sessions/"+url.PathEscape(id), &out); err != nil {
return nil, err
}
return &out, nil
}
// ---------------------------------------------------------------------
// Billing Portal
// ---------------------------------------------------------------------
// CreateBillingPortalSession opens Stripe's hosted "manage my
// subscription" page (update card, change plan, cancel, view invoices)
// for an existing customer.
func (c *Client) CreateBillingPortalSession(ctx context.Context, customerID, returnURL string) (string, error) {
form := url.Values{"customer": {customerID}, "return_url": {returnURL}}
var out struct {
URL string `json:"url"`
}
if err := c.post(ctx, "/billing_portal/sessions", form, &out); err != nil {
return "", err
}
return out.URL, nil
}
// ---------------------------------------------------------------------
// Subscriptions
// ---------------------------------------------------------------------
// StripeSubscription is the subset of a Subscription object handlers
// need to keep internal/db's subscriptions table in sync.
type StripeSubscription struct {
ID string `json:"id"`
Customer string `json:"customer"`
Status string `json:"status"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
CurrentPeriodEnd int64 `json:"current_period_end"` // unix seconds; 0 on API versions that moved this onto items
Metadata map[string]string `json:"metadata"`
Items struct {
Data []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
CurrentPeriodEnd int64 `json:"current_period_end"`
} `json:"data"`
} `json:"items"`
}
// PriceID returns the subscription's single price ID (enkly's checkout
// always creates a one-line-item subscription), or "" if absent.
func (s StripeSubscription) PriceID() string {
if len(s.Items.Data) == 0 {
return ""
}
return s.Items.Data[0].Price.ID
}
// PeriodEndUnix returns current_period_end, preferring the
// subscription-level field and falling back to the first item's (newer
// Stripe API versions report it per-item instead).
func (s StripeSubscription) PeriodEndUnix() int64 {
if s.CurrentPeriodEnd != 0 {
return s.CurrentPeriodEnd
}
if len(s.Items.Data) > 0 {
return s.Items.Data[0].CurrentPeriodEnd
}
return 0
}
// GetSubscription retrieves a subscription by ID — used as a fallback
// when a webhook event's embedded object is missing a field this code
// needs (e.g. an older API version, or a checkout.session.completed
// event that only references the subscription by ID).
func (c *Client) GetSubscription(ctx context.Context, id string) (*StripeSubscription, error) {
var out StripeSubscription
if err := c.get(ctx, "/subscriptions/"+url.PathEscape(id), &out); err != nil {
return nil, err
}
return &out, nil
}
// ---------------------------------------------------------------------
// Webhooks
// ---------------------------------------------------------------------
// Event is the minimal shape of a Stripe webhook event enkly needs:
// enough to dispatch on Type and unmarshal Data.Object into the
// concrete struct (CheckoutSession / StripeSubscription) each handler
// expects.
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Data struct {
Object json.RawMessage `json:"object"`
} `json:"data"`
}
// maxWebhookSkew rejects a webhook whose signed timestamp is further
// than this from "now" — Stripe's own recommended replay-attack guard.
const maxWebhookSkew = 5 * time.Minute
// ConstructEvent verifies a webhook payload's "Stripe-Signature" header
// against webhookSecret and, only if it's genuine, parses it into an
// Event. This reimplements Stripe's documented signature scheme
// (HMAC-SHA256 over "{timestamp}.{payload}", header format
// "t=…,v1=…,v1=…") by hand rather than pulling in the SDK just for
// this one function.
func ConstructEvent(payload []byte, sigHeader, webhookSecret string) (*Event, error) {
if webhookSecret == "" {
return nil, errors.New("billing: STRIPE_WEBHOOK_SECRET is not configured")
}
ts, sigs, err := parseSignatureHeader(sigHeader)
if err != nil {
return nil, err
}
if skew := time.Since(time.Unix(ts, 0)); skew > maxWebhookSkew || skew < -maxWebhookSkew {
return nil, fmt.Errorf("billing: webhook timestamp %s outside tolerance", time.Unix(ts, 0))
}
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(strconv.FormatInt(ts, 10)))
mac.Write([]byte("."))
mac.Write(payload)
want := mac.Sum(nil)
ok := false
for _, sig := range sigs {
got, err := hex.DecodeString(sig)
if err == nil && hmac.Equal(got, want) {
ok = true
break
}
}
if !ok {
return nil, errors.New("billing: webhook signature verification failed")
}
var ev Event
if err := json.Unmarshal(payload, &ev); err != nil {
return nil, fmt.Errorf("billing: decode webhook payload: %w", err)
}
return &ev, nil
}
// parseSignatureHeader splits "t=1614556800,v1=abc…,v1=def…" (Stripe
// includes a second v1 briefly while rotating a webhook signing
// secret) into the timestamp and the list of v1 signatures to try.
func parseSignatureHeader(header string) (ts int64, sigs []string, err error) {
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "t":
ts, err = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0, nil, fmt.Errorf("billing: bad webhook timestamp: %w", err)
}
case "v1":
sigs = append(sigs, strings.TrimSpace(v))
}
}
if ts == 0 || len(sigs) == 0 {
return 0, nil, errors.New("billing: malformed Stripe-Signature header")
}
return ts, sigs, nil
}

View File

@@ -0,0 +1,141 @@
package grpc
import (
"context"
"google.golang.org/protobuf/types/known/timestamppb"
billingv1 "enkly/gen/billing/v1"
"enkly/pkg/grpcerr"
"enkly/services/billing/internal/application"
"enkly/services/billing/internal/domain"
)
type Server struct {
billingv1.UnimplementedBillingServiceServer
Svc *application.Service
PublishableKey string // STRIPE_PUBLISH_KEY, for GetPlanCatalogResponse
}
func New(svc *application.Service, publishableKey string) *Server {
return &Server{Svc: svc, PublishableKey: publishableKey}
}
func toProtoPlan(p domain.Plan) billingv1.Plan {
switch p {
case domain.PlanFree:
return billingv1.Plan_PLAN_FREE
case domain.PlanBasic:
return billingv1.Plan_PLAN_BASIC
case domain.PlanPro:
return billingv1.Plan_PLAN_PRO
case domain.PlanPremium:
return billingv1.Plan_PLAN_PREMIUM
default:
return billingv1.Plan_PLAN_UNSPECIFIED
}
}
func fromProtoPlan(p billingv1.Plan) domain.Plan {
switch p {
case billingv1.Plan_PLAN_FREE:
return domain.PlanFree
case billingv1.Plan_PLAN_BASIC:
return domain.PlanBasic
case billingv1.Plan_PLAN_PRO:
return domain.PlanPro
case billingv1.Plan_PLAN_PREMIUM:
return domain.PlanPremium
default:
return domain.PlanFree
}
}
func toProtoSubscription(s *domain.Subscription) *billingv1.Subscription {
out := &billingv1.Subscription{
UserId: s.UserID,
Plan: toProtoPlan(domain.Plan(s.Plan)),
Status: s.Status,
CancelAtPeriodEnd: s.CancelAtPeriodEnd,
HasBillingAccount: s.StripeCustomerID != "",
}
if !s.CurrentPeriodEnd.IsZero() {
out.CurrentPeriodEnd = timestamppb.New(s.CurrentPeriodEnd)
}
return out
}
func (s *Server) GetOrCreateSubscription(ctx context.Context, req *billingv1.GetOrCreateSubscriptionRequest) (*billingv1.Subscription, error) {
sub, err := s.Svc.GetOrCreateSubscription(ctx, req.GetUserId())
if err != nil {
return nil, grpcerr.Internal(err)
}
return toProtoSubscription(sub), nil
}
func (s *Server) GetPlanCatalog(ctx context.Context, req *billingv1.GetPlanCatalogRequest) (*billingv1.GetPlanCatalogResponse, error) {
entries := s.Svc.GetPlanCatalog(ctx)
resp := &billingv1.GetPlanCatalogResponse{PublishableKey: s.PublishableKey}
for _, e := range entries {
resp.Plans = append(resp.Plans, &billingv1.PlanInfo{
Plan: toProtoPlan(e.Plan),
Name: e.Name,
PriceEur: int32(e.PriceEUR),
DailyMessageLimit: int32(e.DailyMessageLimit),
Available: e.Available,
})
}
return resp, nil
}
func (s *Server) CreateCheckoutSession(ctx context.Context, req *billingv1.CreateCheckoutSessionRequest) (*billingv1.CreateCheckoutSessionResponse, error) {
url, err := s.Svc.CreateCheckoutSession(ctx, req.GetUserId(), req.GetEmail(), fromProtoPlan(req.GetPlan()), req.GetSuccessUrl(), req.GetCancelUrl())
if err != nil {
return nil, grpcerr.FailedPrecondition(err.Error())
}
return &billingv1.CreateCheckoutSessionResponse{CheckoutUrl: url}, nil
}
func (s *Server) SyncCheckoutSession(ctx context.Context, req *billingv1.SyncCheckoutSessionRequest) (*billingv1.Subscription, error) {
sub, err := s.Svc.SyncCheckoutSession(ctx, req.GetUserId(), req.GetStripeSessionId())
if err != nil {
return nil, grpcerr.FailedPrecondition(err.Error())
}
return toProtoSubscription(sub), nil
}
func (s *Server) CreateBillingPortalSession(ctx context.Context, req *billingv1.CreateBillingPortalSessionRequest) (*billingv1.CreateBillingPortalSessionResponse, error) {
url, err := s.Svc.CreateBillingPortalSession(ctx, req.GetUserId(), req.GetReturnUrl())
if err != nil {
if application.IsNoBillingAccount(err) {
return nil, grpcerr.FailedPrecondition("no billing account yet — subscribe to a paid plan first")
}
return nil, grpcerr.FailedPrecondition(err.Error())
}
return &billingv1.CreateBillingPortalSessionResponse{PortalUrl: url}, nil
}
func (s *Server) IngestStripeWebhook(ctx context.Context, req *billingv1.IngestStripeWebhookRequest) (*billingv1.IngestStripeWebhookResponse, error) {
if err := s.Svc.IngestStripeWebhook(ctx, req.GetPayload(), req.GetSignatureHeader()); err != nil {
return nil, grpcerr.InvalidArgument(err.Error())
}
return &billingv1.IngestStripeWebhookResponse{Accepted: true}, nil
}
func (s *Server) CheckAndIncrementUsage(ctx context.Context, req *billingv1.CheckAndIncrementUsageRequest) (*billingv1.CheckAndIncrementUsageResponse, error) {
allowed, used, limit, planName, err := s.Svc.CheckAndIncrementUsage(ctx, req.GetUserId())
if err != nil {
return nil, grpcerr.Internal(err)
}
return &billingv1.CheckAndIncrementUsageResponse{
Allowed: allowed, Used: int32(used), Limit: int32(limit), PlanName: planName,
}, nil
}
func (s *Server) GetDailyUsage(ctx context.Context, req *billingv1.GetDailyUsageRequest) (*billingv1.GetDailyUsageResponse, error) {
used, err := s.Svc.GetDailyUsage(ctx, req.GetUserId())
if err != nil {
return nil, grpcerr.Internal(err)
}
return &billingv1.GetDailyUsageResponse{Used: int32(used)}, nil
}

View File

@@ -0,0 +1,47 @@
-- =====================================================================
-- billing service — 0001_init.sql
-- Database-per-service: owns `subscriptions`, `stripe_events` and
-- `usage_daily`. user_id columns below are bare BIGINTs, NOT a foreign
-- key to iam's users(id) — that table lives in a different database.
-- Referential integrity across that boundary is enforced by the
-- application layer (iam is the only service that can create a
-- user_id in the first place) rather than by Postgres; see
-- ARCHITECTURE.md's "Database-per-service" section.
-- =====================================================================
CREATE TABLE IF NOT EXISTS subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'basic', 'pro', 'premium')),
status TEXT NOT NULL DEFAULT 'active',
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_price_id TEXT,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS subscriptions_stripe_customer_id_idx
ON subscriptions (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_stripe_subscription_id_idx
ON subscriptions (stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL;
-- Idempotency log for incoming webhooks — see application.Service.
-- IngestStripeWebhook.
CREATE TABLE IF NOT EXISTS stripe_events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Per-user, per-UTC-day message counter backing each plan's
-- DailyMessageLimit (see domain.PlanInfo).
CREATE TABLE IF NOT EXISTS usage_daily (
user_id BIGINT NOT NULL,
day DATE NOT NULL,
messages INT NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day)
);

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()
);

View File

@@ -0,0 +1,21 @@
# See services/iam/Dockerfile for the build-stage pattern. This image
# additionally ships web/templates and web/static, since the gateway is
# the only service that renders HTML.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/gateway/ services/gateway/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/gateway-server ./services/gateway/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/gateway-server ./gateway-server
COPY --from=build /src/services/gateway/web ./web
ENV ENKLY_GATEWAY_HTTP_ADDR=:8080
ENV ENKLY_TEMPLATES_DIR=/app/web/templates
ENV ENKLY_STATIC_DIR=/app/web/static
EXPOSE 8080
ENTRYPOINT ["./gateway-server"]

View File

@@ -0,0 +1,173 @@
// Command server runs the gateway: the BFF that terminates the public
// HTTP/HTMX surface and composes it from every other bounded context
// over gRPC. Replaces the net/http wiring half of the monolith's
// cmd/server/main.go.
package main
import (
"context"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
agentv1 "enkly/gen/agent/v1"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
iamv1 "enkly/gen/iam/v1"
knowledgev1 "enkly/gen/knowledge/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/envkit"
"enkly/services/gateway/internal/handlers"
)
func dial(addr string, extraOpts ...grpc.DialOption) *grpc.ClientConn {
opts := append([]grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, extraOpts...)
conn, err := grpc.NewClient(addr, opts...)
if err != nil {
panic(err) // unreachable at startup is fatal; see main()'s recover-free startup sequence below
}
return conn
}
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 (
httpAddr = envkit.String("ENKLY_GATEWAY_HTTP_ADDR", ":8080")
baseURL = envkit.String("ENKLY_BASE_URL", "http://localhost:8080")
templatesDir = envkit.String("ENKLY_TEMPLATES_DIR", "web/templates")
staticDir = envkit.String("ENKLY_STATIC_DIR", "web/static")
iamAddr = envkit.String("ENKLY_IAM_GRPC_ADDR", "iam:9001")
billingAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", "billing:9002")
conversationAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", "conversation:9003")
workspaceAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", "workspace:9004")
knowledgeAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", "knowledge:9005")
agentAddr = envkit.String("ENKLY_AGENT_GRPC_ADDR", "agent:9006")
stripePublishKey = envkit.String("STRIPE_PUBLISH_KEY", "")
)
templates, err := handlers.LoadTemplates(templatesDir)
if err != nil {
logger.Error("load templates", "err", err)
os.Exit(1)
}
bigMsg := grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(24<<20), grpc.MaxCallSendMsgSize(24<<20))
h := &handlers.Handlers{
IAM: iamv1.NewIAMServiceClient(dial(iamAddr)),
Billing: billingv1.NewBillingServiceClient(dial(billingAddr)),
Conversation: conversationv1.NewConversationServiceClient(dial(conversationAddr)),
Workspace: workspacev1.NewWorkspaceServiceClient(dial(workspaceAddr, bigMsg)),
Knowledge: knowledgev1.NewKnowledgeServiceClient(dial(knowledgeAddr)),
Agent: agentv1.NewAgentServiceClient(dial(agentAddr, bigMsg)),
Templates: templates,
Logger: logger,
BaseURL: baseURL,
StripePublishKey: stripePublishKey,
}
mux := http.NewServeMux()
// --- Health check (probed by k8s readinessProbe/livenessProbe in
// k8s/27-gateway.yaml at GET /healthz — must stay unauthenticated
// and dependency-free so a downstream gRPC outage doesn't also
// take the gateway pod itself out of rotation). ---
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// --- Public (no auth) ---
mux.HandleFunc("GET /login", h.LoginPage)
mux.HandleFunc("POST /login", h.RequireCSRF(h.Login))
mux.HandleFunc("GET /signup", h.SignupPage)
mux.HandleFunc("POST /signup", h.RequireCSRF(h.Signup))
mux.HandleFunc("GET /verify-email", h.VerifyEmailPage)
mux.HandleFunc("POST /verify-email", h.RequireCSRF(h.VerifyEmail))
mux.HandleFunc("POST /verify-email/resend", h.RequireCSRF(h.ResendVerification))
mux.HandleFunc("GET /forgot-password", h.ForgotPasswordPage)
mux.HandleFunc("POST /forgot-password", h.RequireCSRF(h.ForgotPassword))
mux.HandleFunc("GET /reset-password", h.ResetPasswordPage)
mux.HandleFunc("POST /reset-password", h.RequireCSRF(h.ResetPassword))
mux.HandleFunc("POST /logout", h.Logout)
mux.HandleFunc("POST /webhooks/stripe", h.StripeWebhook)
mux.HandleFunc("POST /theme", h.SetTheme)
mux.HandleFunc("POST /language", h.SetLanguage)
// --- Authenticated ---
mux.HandleFunc("GET /{$}", h.RequireAuth(h.Index))
mux.HandleFunc("GET /c/{id}", h.RequireAuth(h.SwitchConversation))
mux.HandleFunc("POST /conversations/new", h.RequireAuth(h.RequireCSRF(h.NewConversation)))
mux.HandleFunc("POST /conversations/{id}/rename", h.RequireAuth(h.RequireCSRF(h.RenameConversation)))
mux.HandleFunc("POST /conversations/{id}/delete", h.RequireAuth(h.RequireCSRF(h.DeleteConversationHandler)))
mux.HandleFunc("POST /chat", h.RequireAuth(h.RequireCSRF(h.Chat)))
mux.HandleFunc("GET /chat/stream/{id}", h.RequireAuth(h.StreamChat))
mux.HandleFunc("POST /chat/stream/{id}/stop", h.RequireAuth(h.RequireCSRF(h.StopStream)))
mux.HandleFunc("GET /tree", h.RequireAuth(h.FileTree))
mux.HandleFunc("GET /status", h.RequireAuth(h.StatusBar))
mux.HandleFunc("GET /files", h.RequireAuth(h.FilesPanel))
mux.HandleFunc("POST /files/upload", h.RequireAuth(h.RequireCSRFUpload(h.UploadFile)))
mux.HandleFunc("POST /files/attach", h.RequireAuth(h.RequireCSRFUpload(h.AttachFile)))
mux.HandleFunc("GET /files/download/{path...}", h.RequireAuth(h.DownloadFile))
mux.HandleFunc("GET /subscription", h.RequireAuth(h.SubscriptionPage))
mux.HandleFunc("POST /subscription/checkout", h.RequireAuth(h.RequireCSRF(h.CreateCheckout)))
mux.HandleFunc("POST /subscription/portal", h.RequireAuth(h.RequireCSRF(h.BillingPortal)))
mux.HandleFunc("GET /subscription/success", h.RequireAuth(h.SubscriptionSuccess))
mux.HandleFunc("GET /subscription/cancel", h.RequireAuth(h.SubscriptionCancel))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
// Shared edge middleware — security headers, gzip, structured
// request logging, panic recovery — applied around the whole mux,
// identical ordering to the monolith's.
root := handlers.Chain(mux,
handlers.Recover(logger),
handlers.RequestLogger(logger),
handlers.SecurityHeaders,
handlers.Gzip,
)
lis, err := net.Listen("tcp", httpAddr)
if err != nil {
logger.Error("listen", "addr", httpAddr, "err", err)
os.Exit(1)
}
srv := &http.Server{
Handler: root,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("gateway listening", "addr", httpAddr, "base_url", baseURL)
if err := srv.Serve(lis); err != nil && err != http.ErrServerClosed {
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")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}

View File

@@ -0,0 +1,481 @@
// Package handlers (attachments.go) adds the chat composer's "Add
// files or photos" attachment flow — strongly inspired by Claude
// Code's chat input: drag a file (or a screenshot) onto the message
// box, or click the paperclip, and it shows up as a chip with a
// thumbnail while it uploads in the background; remove it before
// sending if it was a mistake. A .zip is handled specially (see
// "Zip extraction" below): instead of landing as one opaque binary
// blob the agent's tools can't do anything useful with, it's unpacked
// into real workspace files the agent can read_file, grep, and
// list_directory exactly like any other code it's handed — no new
// agent tool needed, "read and review" falls out of tools that
// already exist.
//
// Wire format: web/static/js/app.js uploads each staged file the
// moment it's picked/dropped/pasted, one at a time, to POST
// /files/attach (AttachFile below). That handler is a thin wrapper
// around files.go's writeUploadedFile — the same multipart-part ->
// Workspace.UploadFile path the sidebar's bulk upload form already
// uses — except it (a) namespaces attachments under attachments/ so
// they don't clutter the workspace root the agent writes code into,
// (b) de-dupes the filename against what's already there (see
// uniqueWorkspacePath; UploadFile itself silently overwrites), and
// (c) answers with JSON instead of an HTML fragment, since each
// chip's uploading/done/error state is owned by the browser, not the
// server.
//
// Storage: conversation.Message (api/proto/conversation/v1) has no
// field for "files attached to this message" — adding one would be a
// schema change across a service boundary well beyond this task's
// scope. Instead, StartTurn's prompt argument IS what gets persisted
// as the message's content (see agent's StartTurn and
// internal/application/service.go), so the uploaded paths ride along
// as a trailing, both human- and machine-readable block:
//
// <what the user typed>
//
// [Attached files]
// - attachments/diagram.png
// - attachments/project (12 files)
//
// appendAttachmentsBlock writes it, splitAttachments reads it back.
// The model sees plain, readable text it can act on (e.g. read_file
// one of the paths, or list_directory an extracted folder);
// renderUserMessage parses the same string back into prompt text +
// attachment chips for both the just-sent bubble (chat.go's Chat) and
// every past message on reload (pages.go's prepareMessages) — one
// parser, two call sites, same as how deliverables.go's producedPath
// is shared across a live stream and history. See ARCHITECTURE.md's
// "known gaps" for the same trade-off made elsewhere in this codebase.
package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"mime/multipart"
"net/http"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/renderkit"
)
const (
// attachmentsDir keeps chat attachments out of the workspace root,
// where the agent reads/writes/greps code — see package doc.
attachmentsDir = "attachments"
// attachmentFormField is both the name of the hidden <input> the
// composer form adds per uploaded attachment (app.js) and the
// multipart field AttachFile reads a single file from.
attachmentFormField = "attachments"
// maxChatAttachments mirrors maxUploadFilesPerRequest: a sanity
// cap on how many files (or zips) one message can carry, enforced
// server-side regardless of what the client already limits to.
// A single extracted zip still counts as exactly one of these,
// however many files end up inside it.
maxChatAttachments = 8
)
var imageExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".webp": true, ".svg": true, ".bmp": true, ".avif": true,
}
func isImagePath(p string) bool { return imageExts[strings.ToLower(path.Ext(p))] }
// ---------------------------------------------------------------------
// POST /files/attach — one file per request, called by app.js the
// moment a file is staged (picked, dropped, or pasted).
// ---------------------------------------------------------------------
type attachFileResult struct {
Path string `json:"path"`
Name string `json:"name"`
IsImage bool `json:"isImage"`
IsArchive bool `json:"isArchive"` // true: Path is a folder of extracted files, not one file
EntryCount int `json:"entryCount,omitempty"` // set when IsArchive
DownloadURL string `json:"downloadUrl,omitempty"` // empty for an archive — there's no single file to download
SizeHuman string `json:"sizeHuman"`
}
func (h *Handlers) AttachFile(w http.ResponseWriter, r *http.Request) {
var headers []*multipart.FileHeader
if r.MultipartForm != nil {
headers = r.MultipartForm.File[uploadFormField]
}
if len(headers) != 1 {
writeJSONError(w, http.StatusBadRequest, "attach one file per request")
return
}
fh := headers[0]
if isZipPath(fh.Filename) {
result, err := h.extractZipAttachment(r, fh)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
return
}
rel, err := h.saveChatAttachment(r, fh)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
var size int64
if stat, err := h.Workspace.StatFile(r.Context(), &workspacev1.StatFileRequest{Path: rel}); err == nil {
size = stat.GetSize()
}
writeJSON(w, http.StatusOK, attachFileResult{
Path: rel, Name: path.Base(rel), IsImage: isImagePath(rel),
DownloadURL: downloadURLFor(rel), SizeHuman: humanBytes(size),
})
}
func (h *Handlers) saveChatAttachment(r *http.Request, fh *multipart.FileHeader) (string, error) {
base, err := uploadDestPath("", fh.Filename)
if err != nil {
return "", err
}
rel, err := h.uniqueWorkspacePath(r.Context(), path.Join(attachmentsDir, base))
if err != nil {
return "", err
}
return h.writeUploadedFile(r, rel, fh)
}
// uniqueWorkspacePath appends " (2)", " (3)", … before the extension
// until StatFile reports nothing at that path — the way a desktop
// file manager avoids clobbering an existing file. Needed because
// workspace's UploadFile writes with os.WriteFile, which truncates in
// place: two attachments named the same thing in one conversation
// would otherwise silently overwrite each other. Works equally for a
// plain file's path and an extraction folder's path — StatFile
// reports IsDir either way, and all this cares about is Exists.
func (h *Handlers) uniqueWorkspacePath(ctx context.Context, rel string) (string, error) {
ext := path.Ext(rel)
base := strings.TrimSuffix(rel, ext)
for n := 1; n <= 1000; n++ {
candidate := rel
if n > 1 {
candidate = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
stat, err := h.Workspace.StatFile(ctx, &workspacev1.StatFileRequest{Path: candidate})
if err != nil {
// Can't check — don't block the upload over it, just take
// the first candidate; UploadFile will still succeed.
return candidate, nil
}
if !stat.GetExists() {
return candidate, nil
}
}
return rel, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------
// Zip extraction. A .zip attached to the chat is unpacked into its
// own folder under attachments/ — e.g. attachments/project/ — rather
// than stored as a single binary blob, so the agent's existing
// read_file / list_directory / glob_search / grep tools (all already
// scoped to the whole workspace) can actually read and review what's
// inside it. Nothing about those tools changes; extraction is the
// entire feature.
// ---------------------------------------------------------------------
const (
// maxZipEntries mirrors workspace's own maxWorkspaceFilesListed —
// the same "a listing/extraction this big is almost certainly not
// something a person meant to attach to a chat message" cap.
maxZipEntries = 500
// maxZipUncompressedBytes bounds the classic zip-bomb case: the
// compressed upload itself is already capped at maxUploadFileBytes
// (20 MiB) by the ordinary multipart size limit, but a small zip
// can still decompress to something enormous. 200 MiB total is
// generous for a real codebase or document set while still being
// a hard, enforced ceiling.
maxZipUncompressedBytes = 200 << 20
)
// isZipPath checks the filename's extension only — deliberately not
// the multipart part's declared Content-Type, which the browser sets
// from that same extension anyway and is trivial to get wrong or omit.
func isZipPath(name string) bool {
return strings.EqualFold(path.Ext(filepath.Base(filepath.ToSlash(name))), ".zip")
}
func (h *Handlers) extractZipAttachment(r *http.Request, fh *multipart.FileHeader) (attachFileResult, error) {
if fh.Size > maxUploadFileBytes {
return attachFileResult{}, fmt.Errorf("%q is %s, larger than the %s zip-upload limit", fh.Filename, humanBytes(fh.Size), humanBytes(maxUploadFileBytes))
}
src, err := fh.Open()
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: %w", fh.Filename, err)
}
defer src.Close()
raw, err := io.ReadAll(io.LimitReader(src, maxUploadFileBytes+1))
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: read: %w", fh.Filename, err)
}
if len(raw) > maxUploadFileBytes {
return attachFileResult{}, fmt.Errorf("%q exceeds the %s zip-upload limit", fh.Filename, humanBytes(maxUploadFileBytes))
}
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return attachFileResult{}, fmt.Errorf("%q doesn't look like a valid zip file", fh.Filename)
}
if len(zr.File) > maxZipEntries {
return attachFileResult{}, fmt.Errorf("%q has too many files to attach (limit %d)", fh.Filename, maxZipEntries)
}
name := strings.TrimSuffix(path.Base(filepath.ToSlash(fh.Filename)), ".zip")
if name == "" {
name = "archive"
}
destDir, err := h.uniqueWorkspacePath(r.Context(), path.Join(attachmentsDir, name))
if err != nil {
return attachFileResult{}, err
}
var totalUncompressed int64
written := 0
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
entry := sanitizeZipEntryName(zf.Name)
if entry == "" {
continue // path-traversal / absolute / empty entry — skip rather than fail the whole zip
}
if zf.UncompressedSize64 > uint64(maxUploadFileBytes) {
return attachFileResult{}, fmt.Errorf("%q inside %q is larger than the %s per-file limit", zf.Name, fh.Filename, humanBytes(maxUploadFileBytes))
}
totalUncompressed += int64(zf.UncompressedSize64)
if totalUncompressed > maxZipUncompressedBytes {
return attachFileResult{}, fmt.Errorf("%q unpacks to more than %s — too large to attach", fh.Filename, humanBytes(maxZipUncompressedBytes))
}
rc, err := zf.Open()
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: %w", zf.Name, err)
}
content, err := io.ReadAll(io.LimitReader(rc, maxUploadFileBytes+1))
rc.Close()
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: read: %w", zf.Name, err)
}
if int64(len(content)) > maxUploadFileBytes {
return attachFileResult{}, fmt.Errorf("%q inside %q is larger than the %s per-file limit", zf.Name, fh.Filename, humanBytes(maxUploadFileBytes))
}
if _, err := h.Workspace.UploadFile(r.Context(), &workspacev1.UploadFileRequest{
Path: path.Join(destDir, entry), Content: content,
}); err != nil {
return attachFileResult{}, fmt.Errorf("%q: %w", entry, err)
}
written++
}
if written == 0 {
return attachFileResult{}, fmt.Errorf("%q doesn't have anything extractable in it", fh.Filename)
}
return attachFileResult{
Path: destDir, Name: path.Base(fh.Filename), IsArchive: true,
EntryCount: written, SizeHuman: humanBytes(totalUncompressed),
}, nil
}
// sanitizeZipEntryName is the standard "zip slip" guard: a malicious
// entry name like "../../somewhere/else" or "/etc/passwd" must never
// be allowed to land outside the extraction folder. Workspace's own
// Toolbox.ResolvePath (services/workspace/internal/infrastructure/
// sandboxfs) independently refuses to write outside the workspace
// root at all — a real second layer, not redundant with this one —
// but that check alone wouldn't stop a crafted entry from escaping
// just the destDir subfolder and overwriting an unrelated file
// elsewhere in the workspace (the agent's own code, say). Returns ""
// for anything that isn't a clean, relative, forward-only path.
func sanitizeZipEntryName(name string) string {
clean := path.Clean(strings.TrimPrefix(filepath.ToSlash(name), "/"))
if clean == "" || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return ""
}
return clean
}
// ---------------------------------------------------------------------
// Encoding attachments into (and back out of) a message's plain-text
// content — see package doc.
// ---------------------------------------------------------------------
// attachmentRef is one "[Attached files]" bullet: a plain file
// (EntryCount 0) or an extracted zip's folder (EntryCount = how many
// files it unpacked into).
type attachmentRef struct {
Path string
EntryCount int
}
const attachmentsBlockHeader = "\n\n[Attached files]\n"
// attachmentsBlockRe matches appendAttachmentsBlock's own output: a
// "[Attached files]" heading followed by one "- path" (or
// "- path (N files)") bullet per line, anchored to the end of the
// string so it only ever strips a block this package wrote, never
// something a user happened to type.
var attachmentsBlockRe = regexp.MustCompile(`(?s)\n\n\[Attached files\]\n((?:- .+\n?)+)$`)
// zipBulletRe recognizes appendAttachmentsBlock's "N files" suffix on
// an extracted-archive bullet; anything that doesn't match it is a
// plain file path.
var zipBulletRe = regexp.MustCompile(`^(.+) \((\d+) files?\)$`)
// appendAttachmentsBlock replaces chat.go's old bare
// `renderkit.PlainStream(prompt)` call site's input: the exact string
// passed to Agent.StartTurn (and therefore persisted verbatim as the
// message's content).
func appendAttachmentsBlock(prompt string, refs []attachmentRef) string {
if len(refs) == 0 {
return prompt
}
var b strings.Builder
b.WriteString(prompt)
b.WriteString(attachmentsBlockHeader)
for _, ref := range refs {
b.WriteString("- ")
b.WriteString(ref.Path)
if ref.EntryCount > 0 {
b.WriteString(fmt.Sprintf(" (%d file", ref.EntryCount))
if ref.EntryCount != 1 {
b.WriteString("s")
}
b.WriteString(")")
}
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
// splitAttachments is appendAttachmentsBlock's inverse.
func splitAttachments(content string) (text string, refs []attachmentRef) {
loc := attachmentsBlockRe.FindStringSubmatchIndex(content)
if loc == nil {
return content, nil
}
text = content[:loc[0]]
for _, line := range strings.Split(strings.TrimRight(content[loc[2]:loc[3]], "\n"), "\n") {
p := strings.TrimPrefix(line, "- ")
if p == "" {
continue
}
if m := zipBulletRe.FindStringSubmatch(p); m != nil {
n, _ := strconv.Atoi(m[2])
refs = append(refs, attachmentRef{Path: m[1], EntryCount: n})
continue
}
refs = append(refs, attachmentRef{Path: p})
}
return text, refs
}
// cleanAttachmentPaths sanitizes the composer form's "attachments"
// values (one hidden <input> per uploaded file or extracted archive —
// see app.js's addHiddenInput, which encodes an archive as
// "path|entryCount"): resolve away any "." / ".." games in the path,
// drop duplicates, cap the count. The workspace service's own jail
// (sandboxfs.Toolbox.ResolvePath) is still the real security boundary
// for anything that later reads these paths; this is just keeping the
// stored/displayed list sane.
func cleanAttachmentPaths(raw []string) []attachmentRef {
seen := make(map[string]bool, len(raw))
out := make([]attachmentRef, 0, len(raw))
for _, v := range raw {
p, count := v, 0
if i := strings.LastIndex(v, "|"); i != -1 {
if n, err := strconv.Atoi(v[i+1:]); err == nil && n > 0 {
p, count = v[:i], n
}
}
clean := strings.TrimPrefix(path.Clean("/"+p), "/")
if clean == "" || clean == "." || seen[clean] {
continue
}
seen[clean] = true
out = append(out, attachmentRef{Path: clean, EntryCount: count})
if len(out) == maxChatAttachments {
break
}
}
return out
}
// defaultAttachmentPrompt replaces chat.go's Chat validating an empty
// prompt the way the agent service always has (service.go's StartTurn
// rejects "" with "Type a message first.") — if files were attached
// with no text at all, that empty-prompt rule shouldn't block the
// send; give it a minimal prompt instead, same spirit as app.js's
// existing "I uploaded X." auto-fill for the sidebar upload flow.
func defaultAttachmentPrompt(refs []attachmentRef) string {
if len(refs) == 1 {
return "Take a look at the attached file."
}
return "Take a look at the attached files."
}
// ---------------------------------------------------------------------
// Rendering a user message's content (prompt text + attachment
// chips) — shared by chat.go (the just-sent bubble) and pages.go
// (every past message), replacing their old direct
// renderkit.PlainStream(content) calls.
// ---------------------------------------------------------------------
type attachmentChipView struct {
Name string
IsImage bool
IsArchive bool
EntryCount int
DownloadURL string // empty for an archive chip — nothing to download as one file
}
func (h *Handlers) renderUserMessage(content string) template.HTML {
text, refs := splitAttachments(content)
html := renderkit.PlainStream(text)
if len(refs) > 0 {
views := make([]attachmentChipView, len(refs))
for i, ref := range refs {
if ref.EntryCount > 0 {
views[i] = attachmentChipView{Name: path.Base(ref.Path), IsArchive: true, EntryCount: ref.EntryCount}
continue
}
views[i] = attachmentChipView{Name: path.Base(ref.Path), IsImage: isImagePath(ref.Path), DownloadURL: downloadURLFor(ref.Path)}
}
html += h.renderString("message_attachments", views)
}
return template.HTML(html) // #nosec G203 -- renderkit.PlainStream escapes the text; message_attachments is a trusted template
}

View File

@@ -0,0 +1,210 @@
// Package handlers (auth.go) replaces internal/handlers/auth.go. Every
// handler here does the same HTTP-shape work as before (parse the
// form, render the auth.html templates) but delegates every password
// check, rate limit, token mint, and email dispatch to one IAM RPC —
// the gateway itself no longer touches a password hash, a session
// table, or SMTP.
package handlers
import (
"errors"
"net/http"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/i18n"
)
type authPageData struct {
CSRFToken string
Email string
Token string // reset_password only
Error string
Info string
Lang string
}
// authData is a small constructor so every call site below gets Lang
// filled in automatically instead of relying on each of the dozen
// authPageData{...} literals to remember it.
func (h *Handlers) authData(r *http.Request, w http.ResponseWriter) authPageData {
return authPageData{CSRFToken: h.csrfToken(w, r), Lang: string(localeFrom(r))}
}
func (h *Handlers) LoginPage(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "login", h.authData(r, w))
}
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
email, _ := normalizeEmail(r.PostFormValue("email"))
resp, err := h.IAM.Login(r.Context(), &iamv1.LoginRequest{
Email: email, Password: r.PostFormValue("password"), ClientIp: clientIP(r),
})
if err != nil {
h.Logger.Error("login", "err", err)
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.generic")
h.render(w, http.StatusInternalServerError, "login", d)
return
}
switch {
case resp.GetOk():
h.startSessionCookie(w, resp.GetSessionToken(), resp.GetSessionExpiresAt().AsTime())
http.Redirect(w, r, "/", http.StatusSeeOther)
case resp.GetNeedsVerification():
http.Redirect(w, r, "/verify-email?email="+email, http.StatusSeeOther)
default:
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.incorrect_credentials")
h.render(w, http.StatusUnauthorized, "login", d)
}
}
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
if raw := sessionToken(r); raw != "" {
_, _ = h.IAM.Logout(r.Context(), &iamv1.LogoutRequest{SessionToken: raw})
}
h.setCookie(w, sessionCookieName, "", -1)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *Handlers) SignupPage(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "signup", h.authData(r, w))
}
func (h *Handlers) Signup(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
email, err := normalizeEmail(r.PostFormValue("email"))
if err != nil {
d := h.authData(r, w)
d.Error = i18n.T(lang, "errors.invalid_email")
h.render(w, http.StatusBadRequest, "signup", d)
return
}
if err := validatePassword(r.PostFormValue("password"), r.PostFormValue("password_confirm")); err != nil {
d := h.authData(r, w)
d.Email, d.Error = email, translatePasswordError(lang, err)
h.render(w, http.StatusBadRequest, "signup", d)
return
}
resp, err := h.IAM.SignUp(r.Context(), &iamv1.SignUpRequest{
Email: email, Password: r.PostFormValue("password"), PasswordConfirm: r.PostFormValue("password_confirm"), ClientIp: clientIP(r),
})
if err != nil {
h.Logger.Error("signup", "err", err)
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.generic")
h.render(w, http.StatusInternalServerError, "signup", d)
return
}
info := i18n.T(lang, "info.check_inbox")
if !resp.GetMailConfigured() {
info = i18n.T(lang, "info.mail_not_configured")
}
http.Redirect(w, r, "/verify-email?email="+email+"&info="+info, http.StatusSeeOther)
}
func (h *Handlers) VerifyEmailPage(w http.ResponseWriter, r *http.Request) {
d := h.authData(r, w)
d.Email, d.Info = r.URL.Query().Get("email"), r.URL.Query().Get("info")
h.render(w, http.StatusOK, "verify_email", d)
}
func (h *Handlers) VerifyEmail(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
email, _ := normalizeEmail(r.PostFormValue("email"))
code := r.PostFormValue("code")
if !isSixDigitCode(code) {
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.invalid_code")
h.render(w, http.StatusBadRequest, "verify_email", d)
return
}
resp, err := h.IAM.VerifyEmail(r.Context(), &iamv1.VerifyEmailRequest{Email: email, Code: code, ClientIp: clientIP(r)})
if err != nil {
h.Logger.Error("verify email", "err", err)
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.generic")
h.render(w, http.StatusInternalServerError, "verify_email", d)
return
}
if !resp.GetVerified() {
d := h.authData(r, w)
d.Email, d.Error = email, i18n.T(lang, "errors.code_incorrect")
h.render(w, http.StatusBadRequest, "verify_email", d)
return
}
http.Redirect(w, r, "/login?info="+i18n.T(lang, "info.email_verified"), http.StatusSeeOther)
}
func (h *Handlers) ResendVerification(w http.ResponseWriter, r *http.Request) {
email, _ := normalizeEmail(r.PostFormValue("email"))
_, _ = h.IAM.ResendVerification(r.Context(), &iamv1.ResendVerificationRequest{Email: email, ClientIp: clientIP(r)})
http.Redirect(w, r, "/verify-email?email="+email+"&info="+i18n.T(localeFrom(r), "info.resend_sent"), http.StatusSeeOther)
}
func (h *Handlers) ForgotPasswordPage(w http.ResponseWriter, r *http.Request) {
h.render(w, http.StatusOK, "forgot_password", h.authData(r, w))
}
func (h *Handlers) ForgotPassword(w http.ResponseWriter, r *http.Request) {
email, _ := normalizeEmail(r.PostFormValue("email"))
_, _ = h.IAM.ForgotPassword(r.Context(), &iamv1.ForgotPasswordRequest{Email: email, ClientIp: clientIP(r)})
d := h.authData(r, w)
d.Info = i18n.T(localeFrom(r), "info.forgot_sent")
h.render(w, http.StatusOK, "forgot_password", d)
}
func (h *Handlers) ResetPasswordPage(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
resp, err := h.IAM.CheckPasswordResetToken(r.Context(), &iamv1.CheckPasswordResetTokenRequest{Token: token})
if err != nil || !resp.GetValid() {
d := h.authData(r, w)
d.Error = i18n.T(localeFrom(r), "errors.reset_link_invalid")
h.render(w, http.StatusBadRequest, "reset_password", d)
return
}
d := h.authData(r, w)
d.Token = token
h.render(w, http.StatusOK, "reset_password", d)
}
func (h *Handlers) ResetPassword(w http.ResponseWriter, r *http.Request) {
lang := localeFrom(r)
token := r.PostFormValue("token")
if err := validatePassword(r.PostFormValue("password"), r.PostFormValue("password_confirm")); err != nil {
d := h.authData(r, w)
d.Token, d.Error = token, translatePasswordError(lang, err)
h.render(w, http.StatusBadRequest, "reset_password", d)
return
}
resp, err := h.IAM.ResetPassword(r.Context(), &iamv1.ResetPasswordRequest{
Token: token, Password: r.PostFormValue("password"), PasswordConfirm: r.PostFormValue("password_confirm"),
})
if err != nil || !resp.GetOk() {
d := h.authData(r, w)
d.Token, d.Error = token, i18n.T(lang, "errors.reset_link_invalid")
h.render(w, http.StatusBadRequest, "reset_password", d)
return
}
http.Redirect(w, r, "/login?info="+i18n.T(lang, "info.password_reset_done"), http.StatusSeeOther)
}
// translatePasswordError maps one of validatePassword's sentinel
// errors (see core.go) onto the matching catalog key, via errors.Is
// rather than matching the error's English text — so this still works
// correctly no matter what validatePassword's messages say, or if
// minPasswordLen/maxPasswordLen ever change. Any error that isn't one
// of the three known sentinels falls back to errors.generic.
func translatePasswordError(lang i18n.Locale, err error) string {
switch {
case errors.Is(err, errPasswordTooShort):
return i18n.T(lang, "errors.password_min", minPasswordLen)
case errors.Is(err, errPasswordTooLong):
return i18n.T(lang, "errors.password_max", maxPasswordLen)
case errors.Is(err, errPasswordMismatch):
return i18n.T(lang, "errors.password_mismatch")
default:
return i18n.T(lang, "errors.generic")
}
}

View File

@@ -0,0 +1,282 @@
// Package handlers (billing.go) replaces internal/handlers/billing.go.
// The Stripe webhook keeps its exact public URL (POST /webhooks/stripe)
// so nothing needs to change in the Stripe dashboard — but this handler
// now just reads the raw body and forwards it untouched to billing's
// IngestStripeWebhook RPC, which holds STRIPE_WEBHOOK_SECRET and does
// the actual signature verification; the gateway itself never sees
// Stripe's signing secret.
package handlers
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
billingv1 "enkly/gen/billing/v1"
)
const maxWebhookBodyBytes = 1 << 20
type planView struct {
Plan string
Name string
PriceEUR int
DailyMessageLimit int
IsCurrent bool
Action string
Popular bool
Multiplier string
Checkoutable bool
UnavailableReason string
}
type subscriptionData struct {
CSRFToken string
UserEmail string
Plans []planView
CurrentName string
Status string
CancelAtPeriodEnd bool
CurrentPeriodEnd time.Time
UsedToday int
DailyLimit int
Unlimited bool
UsagePercent int
NearLimit bool
AtLimit bool
HasBillingPortal bool
Message string
Error string
}
func planSlug(p billingv1.Plan) string {
switch p {
case billingv1.Plan_PLAN_FREE:
return "free"
case billingv1.Plan_PLAN_BASIC:
return "basic"
case billingv1.Plan_PLAN_PRO:
return "pro"
case billingv1.Plan_PLAN_PREMIUM:
return "premium"
default:
return "free"
}
}
func planRank(p billingv1.Plan) int {
switch p {
case billingv1.Plan_PLAN_FREE:
return 0
case billingv1.Plan_PLAN_BASIC:
return 1
case billingv1.Plan_PLAN_PRO:
return 2
case billingv1.Plan_PLAN_PREMIUM:
return 3
default:
return 0
}
}
func (h *Handlers) buildSubscriptionData(ctx context.Context, w http.ResponseWriter, r *http.Request) (subscriptionData, error) {
user := userFrom(r)
sub, err := h.Billing.GetOrCreateSubscription(ctx, &billingv1.GetOrCreateSubscriptionRequest{UserId: user.GetId()})
if err != nil {
return subscriptionData{}, err
}
catalog, err := h.Billing.GetPlanCatalog(ctx, &billingv1.GetPlanCatalogRequest{})
if err != nil {
return subscriptionData{}, err
}
curRank := planRank(sub.GetPlan())
var freeLimit int32
for _, p := range catalog.GetPlans() {
if p.GetPlan() == billingv1.Plan_PLAN_FREE {
freeLimit = p.GetDailyMessageLimit()
}
}
plans := make([]planView, 0, len(catalog.GetPlans()))
for _, p := range catalog.GetPlans() {
var reason string
if !p.GetAvailable() {
reason = "Stripe isn't configured on this server yet (missing STRIPE_SECRET_KEY)."
}
action := "Choose " + p.GetName()
switch rk := planRank(p.GetPlan()); {
case p.GetPlan() == billingv1.Plan_PLAN_FREE:
action = "Downgrade to Free"
case rk > curRank:
action = "Upgrade to " + p.GetName()
case rk < curRank:
action = "Switch to " + p.GetName()
}
mult := ""
switch {
case p.GetPlan() == billingv1.Plan_PLAN_FREE:
case p.GetDailyMessageLimit() == 0:
mult = "Unlimited messages — no daily cap"
case freeLimit > 0:
mult = fmt.Sprintf("%d× more messages than Free", p.GetDailyMessageLimit()/freeLimit)
}
plans = append(plans, planView{
Plan: planSlug(p.GetPlan()), Name: p.GetName(), PriceEUR: int(p.GetPriceEur()),
DailyMessageLimit: int(p.GetDailyMessageLimit()), IsCurrent: p.GetPlan() == sub.GetPlan(),
Action: action, Popular: p.GetPlan() == billingv1.Plan_PLAN_PRO, Multiplier: mult,
Checkoutable: p.GetAvailable(), UnavailableReason: reason,
})
}
data := subscriptionData{
CSRFToken: h.csrfToken(w, r), UserEmail: user.GetEmail(), Plans: plans,
CurrentName: planDisplayName(sub.GetPlan()), Status: sub.GetStatus(),
CancelAtPeriodEnd: sub.GetCancelAtPeriodEnd(), HasBillingPortal: sub.GetHasBillingAccount(),
}
if sub.GetCurrentPeriodEnd() != nil {
data.CurrentPeriodEnd = sub.GetCurrentPeriodEnd().AsTime()
}
var limit int
for _, p := range catalog.GetPlans() {
if p.GetPlan() == sub.GetPlan() {
limit = int(p.GetDailyMessageLimit())
}
}
data.DailyLimit = limit
data.Unlimited = limit == 0
if usage, err := h.Billing.GetDailyUsage(ctx, &billingv1.GetDailyUsageRequest{UserId: user.GetId()}); err != nil {
h.Logger.Warn("load daily usage for subscription page", "err", err, "user_id", user.GetId())
} else {
data.UsedToday = int(usage.GetUsed())
if limit > 0 {
data.UsagePercent = min(100, data.UsedToday*100/limit)
data.NearLimit = data.UsedToday*100 >= limit*80
data.AtLimit = data.UsedToday >= limit
}
}
return data, nil
}
func (h *Handlers) SubscriptionPage(w http.ResponseWriter, r *http.Request) {
d, err := h.buildSubscriptionData(r.Context(), w, r)
if err != nil {
h.serverError(w, "load subscription", err)
return
}
switch r.URL.Query().Get("msg") {
case "success":
d.Message = "Payment received — thank you! Your plan is now active."
case "cancelled":
d.Message = "Checkout was cancelled — you weren't charged."
}
h.render(w, http.StatusOK, "subscription", d)
}
func (h *Handlers) subscriptionError(w http.ResponseWriter, r *http.Request, status int, msg string) {
d, err := h.buildSubscriptionData(r.Context(), w, r)
if err != nil {
h.serverError(w, "load subscription", err)
return
}
d.Error = msg
h.render(w, status, "subscription", d)
}
func (h *Handlers) SubscriptionSuccess(w http.ResponseWriter, r *http.Request) {
if id := r.URL.Query().Get("session_id"); id != "" {
if _, err := h.Billing.SyncCheckoutSession(r.Context(), &billingv1.SyncCheckoutSessionRequest{
UserId: userFrom(r).GetId(), StripeSessionId: id,
}); err != nil {
h.Logger.Warn("sync checkout session", "err", err, "session_id", id)
}
}
http.Redirect(w, r, "/subscription?msg=success", http.StatusSeeOther)
}
func (h *Handlers) SubscriptionCancel(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/subscription?msg=cancelled", http.StatusSeeOther)
}
func (h *Handlers) CreateCheckout(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx := r.Context()
planStr := r.PostFormValue("plan")
if planStr == "free" {
h.BillingPortal(w, r)
return
}
resp, err := h.Billing.CreateCheckoutSession(ctx, &billingv1.CreateCheckoutSessionRequest{
UserId: user.GetId(), Email: user.GetEmail(), Plan: parsePlanSlug(planStr),
SuccessUrl: h.subscriptionURL("/subscription/success") + "?session_id={CHECKOUT_SESSION_ID}",
CancelUrl: h.subscriptionURL("/subscription/cancel"),
})
if err != nil {
h.Logger.Error("create checkout session", "err", err, "user_id", user.GetId(), "plan", planStr)
h.subscriptionError(w, r, http.StatusBadGateway, fmt.Sprintf("Couldn't start checkout: %s", err))
return
}
http.Redirect(w, r, resp.GetCheckoutUrl(), http.StatusSeeOther)
}
func (h *Handlers) BillingPortal(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
resp, err := h.Billing.CreateBillingPortalSession(r.Context(), &billingv1.CreateBillingPortalSessionRequest{
UserId: user.GetId(), ReturnUrl: h.subscriptionURL("/subscription"),
})
if err != nil {
h.Logger.Error("create billing portal session", "err", err, "user_id", user.GetId())
h.subscriptionError(w, r, http.StatusBadGateway, fmt.Sprintf("Couldn't open the billing portal: %s", err))
return
}
http.Redirect(w, r, resp.GetPortalUrl(), http.StatusSeeOther)
}
func (h *Handlers) subscriptionURL(path string) string {
return strings.TrimRight(h.BaseURL, "/") + path
}
func parsePlanSlug(s string) billingv1.Plan {
switch s {
case "basic":
return billingv1.Plan_PLAN_BASIC
case "pro":
return billingv1.Plan_PLAN_PRO
case "premium":
return billingv1.Plan_PLAN_PREMIUM
default:
return billingv1.Plan_PLAN_FREE
}
}
// StripeWebhook (POST /webhooks/stripe) — see package doc above.
func (h *Handlers) StripeWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_, err = h.Billing.IngestStripeWebhook(r.Context(), &billingv1.IngestStripeWebhookRequest{
Payload: body, SignatureHeader: r.Header.Get("Stripe-Signature"),
})
if err != nil {
h.Logger.Warn("stripe webhook rejected", "err", err)
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}

View File

@@ -0,0 +1,287 @@
// Package handlers (chat.go) replaces internal/handlers/chat.go's HTTP
// surface. All of the orchestration that file used to do directly
// (quota check, persisting messages, running the agent loop) now
// happens inside the agent service; this file's job is just to call
// StartTurn, translate Agent's gRPC server-stream into the exact same
// SSE wire format the front end (web/static/js/app.js, chat_response/
// assistant_stream in the templates) already expects, and do the one
// thing that must stay here: render HTML (Markdown, tool-call cards,
// deliverable cards) from the plain text/structured data the agent
// service streams back. See core.go's package doc for why rendering
// lives only in this service.
package handlers
import (
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"strings"
"google.golang.org/grpc/metadata"
agentv1 "enkly/gen/agent/v1"
"enkly/pkg/i18n"
"enkly/pkg/renderkit"
)
// langMetadataKey is the gRPC metadata key the gateway uses to tell
// the agent service which locale this visitor has chosen, so the
// model can be told that as a standing preference (see
// agentrun.WithPreferredLanguage) rather than only inferring it
// message-by-message. A metadata header, not a StartTurnRequest
// field: adding a proto field means regenerating every language's
// generated client/server code, and this repo's gen/ directory is
// hand-maintained without network access to protoc in some
// environments — a header costs nothing to add or remove later and
// needs no regeneration on either side. See ARCHITECTURE.md's
// "Language preference storage" note.
const langMetadataKey = "x-enkly-lang"
// withLangMetadata attaches the request's resolved locale (see
// localeFrom) to r's context as outgoing gRPC metadata, for a call to
// the agent service that should know the visitor's chosen UI
// language — currently just StartTurn, which is the one call that
// kicks off a run whose system prompt gets built.
func withLangMetadata(r *http.Request) context.Context {
return metadata.AppendToOutgoingContext(r.Context(), langMetadataKey, string(localeFrom(r)))
}
type chatErrorData struct{ Message string }
type chatResponseData struct {
UserPromptHTML template.HTML
StreamID string
CSRFToken string
}
// Chat replaces Handlers.Chat. Attachments (see attachments.go) ride
// along as repeated "attachments" form values — one per file the
// composer already uploaded in the background before this submit —
// and get folded into the exact prompt string sent to StartTurn (and
// therefore persisted as the message's content), so the agent, the
// just-sent bubble, and every future reload of this conversation all
// see the same thing.
func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
convID, _ := strconv.ParseInt(r.PostFormValue("conversation_id"), 10, 64)
prompt := strings.TrimSpace(r.PostFormValue("prompt"))
attachments := cleanAttachmentPaths(r.PostForm[attachmentFormField])
if prompt == "" && len(attachments) > 0 {
prompt = defaultAttachmentPrompt(attachments)
}
fullPrompt := appendAttachmentsBlock(prompt, attachments)
resp, err := h.Agent.StartTurn(withLangMetadata(r), &agentv1.StartTurnRequest{
UserId: user.GetId(), ConversationId: convID, Prompt: fullPrompt,
})
if err != nil {
h.Logger.Error("start turn", "err", err)
h.render(w, http.StatusInternalServerError, "chat_error", chatErrorData{Message: i18n.T(localeFrom(r), "chat.send_failed")})
return
}
if resp.GetOutcome() != agentv1.StartTurnOutcome_START_TURN_OUTCOME_OK {
status := http.StatusBadRequest
if resp.GetOutcome() == agentv1.StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED {
status = http.StatusPaymentRequired
}
h.render(w, status, "chat_error", chatErrorData{Message: resp.GetMessage()})
return
}
h.render(w, http.StatusOK, "chat_response", chatResponseData{
UserPromptHTML: h.renderUserMessage(fullPrompt),
StreamID: resp.GetStreamId(),
CSRFToken: h.csrfToken(w, r),
})
}
// StreamChat replaces Handlers.StreamChat: opens Agent's server-
// streaming StreamTurn RPC and re-emits every event as an SSE frame.
func (h *Handlers) StreamChat(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
streamID := r.PathValue("id")
resumeFrom := 0
if id := r.Header.Get("Last-Event-ID"); id != "" {
resumeFrom, _ = strconv.Atoi(id)
}
grpcStream, err := h.Agent.StreamTurn(r.Context(), &agentv1.StreamTurnRequest{
UserId: user.GetId(), StreamId: streamID, ResumeFromToolIndex: int32(resumeFrom),
})
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
toolIndex := resumeFrom
var finalToolCalls []*agentv1.ToolCall
// shownDeliverables tracks (by cleanWorkspacePath) every file
// already streamed live via a "deliverable" event below, so the
// "done" event's deliverablesHTML doesn't render a duplicate card
// for the same file — see liveDeliverable/remainingToolCalls.
shownDeliverables := map[string]bool{}
for {
ev, err := grpcStream.Recv()
if err == io.EOF || err != nil {
return
}
switch e := ev.GetEvent().(type) {
case *agentv1.TurnEvent_ThinkingDelta:
writeSSE(w, "thinking", renderkit.PlainStream(e.ThinkingDelta))
case *agentv1.TurnEvent_TokenDelta:
writeSSE(w, "token", renderkit.PlainStream(e.TokenDelta))
case *agentv1.TurnEvent_ToolCall:
toolIndex++
writeSSEID(w, "tool", toolIndex, h.renderToolCallOOB(streamID, e.ToolCall))
// As soon as this tool call has created or changed a
// document (create_document, edit_spreadsheet, ...),
// show its Download button right away — don't make the
// user wait for the whole turn to finish, which can come
// much later if the agent keeps working after this.
if frag, key, ok := h.liveDeliverable(r.Context(), streamID, e.ToolCall, shownDeliverables); ok {
shownDeliverables[key] = true
writeSSE(w, "deliverable", frag)
}
case *agentv1.TurnEvent_ThinkingRound:
writeSSE(w, "thinking_round", h.renderThinkingRoundOOB(streamID, e.ThinkingRound))
case *agentv1.TurnEvent_Done:
finalToolCalls = e.Done.GetToolCalls()
html := renderkit.Markdown(e.Done.GetFinalText())
html += h.deliverablesHTML(r.Context(), remainingToolCalls(finalToolCalls, shownDeliverables))
writeSSE(w, "done", html)
if flusher != nil {
flusher.Flush()
}
return
}
if flusher != nil {
flusher.Flush()
}
}
}
// StopStream replaces Handlers.StopStream.
func (h *Handlers) StopStream(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
_, err := h.Agent.StopTurn(r.Context(), &agentv1.StopTurnRequest{UserId: user.GetId(), StreamId: r.PathValue("id")})
if err != nil {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// SSE framing + tool-card rendering
// ---------------------------------------------------------------------
func writeSSE(w http.ResponseWriter, event, data string) {
fmt.Fprintf(w, "event: %s\n", event)
for _, line := range splitLines(data) {
fmt.Fprintf(w, "data: %s\n", line)
}
fmt.Fprint(w, "\n")
}
func writeSSEID(w http.ResponseWriter, event string, id int, data string) {
fmt.Fprintf(w, "id: %d\n", id)
writeSSE(w, event, data)
}
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
type toolCallView struct {
StreamID string
ToolName string
Summary string
Body template.HTML
IsError bool
}
// renderToolCallOOB replaces h.renderToolOOB: builds the
// hx-swap-oob-wrapped tool card for one completed tool call.
func (h *Handlers) renderToolCallOOB(streamID string, tc *agentv1.ToolCall) string {
return h.renderString("tool_call_oob", toolCallView{
StreamID: streamID,
ToolName: tc.GetName(),
Summary: summarizeToolCall(tc.GetName(), tc.GetArgumentsJson()),
Body: toolBodyHTML(tc.GetResult()),
IsError: tc.GetIsError(),
})
}
type thinkingRoundView struct {
StreamID string
Round int32
Thinking string
}
// renderThinkingRoundOOB builds the hx-swap-oob-wrapped card for one
// round's standalone reasoning trace — the TurnEvent_ThinkingRound
// counterpart to renderToolCallOOB. Unlike tool cards, there's no
// resume cursor for these (see server.go's StreamTurn comment on
// sentThinkRounds): a client that reconnects mid-turn will see
// already-shown round cards inserted again. Acceptable for now since
// a run has at most MaxRounds of these, each a short card, and the
// existing cumulative "thinking" delta already re-sends in full on
// every reconnect anyway.
func (h *Handlers) renderThinkingRoundOOB(streamID string, tr *agentv1.ThinkingRound) string {
return h.renderString("thinking_round_oob", thinkingRoundView{
StreamID: streamID,
Round: tr.GetRound(),
Thinking: tr.GetThinking(),
})
}
// summarizeToolCall replaces handlers.summarizeCall — a short,
// human-readable one-liner shown in the card's collapsed summary row
// (e.g. "internal/db/users.go" for a read_file call). Tries the
// arguments' most identifying field first (path, pattern, command,
// query); falls back to the raw (truncated) arguments JSON.
func summarizeToolCall(name, argsJSON string) string {
if argsJSON == "" || argsJSON == "{}" {
return ""
}
var args map[string]any
if err := json.Unmarshal([]byte(argsJSON), &args); err == nil {
for _, key := range []string{"path", "pattern", "command", "query", "task"} {
if v, ok := args[key].(string); ok && v != "" {
return truncateSummary(v)
}
}
}
return truncateSummary(argsJSON)
}
const maxSummaryChars = 80
func truncateSummary(s string) string {
if len(s) > maxSummaryChars {
return s[:maxSummaryChars] + "…"
}
return s
}

View File

@@ -0,0 +1,279 @@
// Package handlers is the gateway's net/http layer — the BFF (backend
// for frontend) that terminates the public HTTP/HTMX surface and
// composes it from gRPC calls to every other bounded context. It is
// the direct transplant of the monolith's internal/handlers package,
// with one systematic change: everywhere the original called h.DB,
// h.Agent, or h.Billing directly, this package calls the matching
// service's generated gRPC client instead. Rendering (html/template,
// pkg/renderkit's Markdown/syntax-highlighting) stays here — this is
// now the ONLY service that imports pkg/renderkit or parses *.html
// templates; see ARCHITECTURE.md's "Where rendering lives".
package handlers
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"net/mail"
"path/filepath"
"strings"
"time"
agentv1 "enkly/gen/agent/v1"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
iamv1 "enkly/gen/iam/v1"
knowledgev1 "enkly/gen/knowledge/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/authsupport"
"enkly/pkg/i18n"
)
const (
sessionCookieName = "enkly_session"
csrfCookieName = "enkly_csrf"
csrfFormField = "csrf_token"
sessionTTL = 30 * 24 * time.Hour
maxFormBytes = 1 << 20 // 1 MiB
maxTitleChars = 120
minPasswordLen = 8
maxPasswordLen = 256
maxEmailLen = 254
)
// Handlers bundles every dependency an HTTP handler needs: the
// generated gRPC clients for each peer bounded context, the parsed
// templates, and the gateway's own edge-only state (CSRF — there is no
// server-side CSRF table, it's a pure double-submit cookie, so nothing
// about it belongs in any other service).
type Handlers struct {
IAM iamv1.IAMServiceClient
Billing billingv1.BillingServiceClient
Conversation conversationv1.ConversationServiceClient
Workspace workspacev1.WorkspaceServiceClient
Knowledge knowledgev1.KnowledgeServiceClient
Agent agentv1.AgentServiceClient
Templates *template.Template
Logger *slog.Logger
BaseURL string
StripePublishKey string
}
// templateFuncs are the template functions every *.html file can call
// regardless of which handler rendered it. Kept to pure functions of
// their arguments (never a closure over per-request state) because
// Templates is parsed once at startup and shared across every
// request — see LoadTemplates.
var templateFuncs = template.FuncMap{
// t looks up a translation key in the given locale, falling back
// to English and then to the key itself — see pkg/i18n.T's doc
// comment. Usage in a template: {{t .Lang "nav.new_chat"}}, or
// with Sprintf-style args: {{t .Lang "errors.password_min" 8}}.
"t": func(locale, key string, args ...any) string {
return i18n.T(i18n.Locale(locale), key, args...)
},
// supportedLanguages lists every locale enkly ships a catalog for,
// for the language-switcher partial to range over. A pure function
// of no arguments (the list is the same for every request), so —
// unlike the current locale — it needs no per-page field.
"supportedLanguages": func() []i18n.Info { return i18n.Supported },
// dict builds a map[string]any from alternating key/value
// arguments, so a page can pass its own CSRFToken/Lang/Next into a
// shared partial (e.g. "lang_switcher") that isn't handed the
// whole page's data value. Standard html/template idiom — the
// stdlib has no built-in equivalent.
"dict": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("dict: argument %d is not a string key", i)
}
m[key] = pairs[i+1]
}
return m, nil
},
}
// LoadTemplates parses every *.html file in dir, registering
// templateFuncs so every template can call {{t .Lang "..."}} and
// {{supportedLanguages}} — otherwise unchanged from the original
// handlers.LoadTemplates.
func LoadTemplates(dir string) (*template.Template, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.html"))
if err != nil {
return nil, fmt.Errorf("gateway: glob templates: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("gateway: no *.html templates found in %s", dir)
}
t, err := template.New("enkly").Funcs(templateFuncs).ParseFiles(files...)
if err != nil {
return nil, fmt.Errorf("gateway: parse templates: %w", err)
}
return t, nil
}
func (h *Handlers) render(w http.ResponseWriter, status int, name string, data any) {
var buf bytes.Buffer
if err := h.Templates.ExecuteTemplate(&buf, name, data); err != nil {
h.Logger.Error("render template", "template", name, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
func (h *Handlers) renderString(name string, data any) string {
var buf bytes.Buffer
if err := h.Templates.ExecuteTemplate(&buf, name, data); err != nil {
h.Logger.Error("render fragment", "template", name, "err", err)
return ""
}
return buf.String()
}
// ---------------------------------------------------------------------
// Request context: the authenticated user (now the IAM-service User,
// via the generated client, instead of models.User)
// ---------------------------------------------------------------------
type ctxKey int
const userCtxKey ctxKey = iota
func withUser(ctx context.Context, u *iamv1.User) context.Context {
return context.WithValue(ctx, userCtxKey, u)
}
func userFrom(r *http.Request) *iamv1.User {
u, _ := r.Context().Value(userCtxKey).(*iamv1.User)
return u
}
// ---------------------------------------------------------------------
// Cookies
// ---------------------------------------------------------------------
func (h *Handlers) secureCookies() bool {
return strings.HasPrefix(strings.ToLower(h.BaseURL), "https://")
}
func (h *Handlers) setCookie(w http.ResponseWriter, name, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: value, Path: "/", MaxAge: maxAge,
HttpOnly: true, Secure: h.secureCookies(), SameSite: http.SameSiteLaxMode,
})
}
// csrfToken replaces Handlers.csrfToken.
func (h *Handlers) csrfToken(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
return c.Value
}
tok, err := authsupport.NewCSRFToken()
if err != nil {
h.Logger.Error("csrf token", "err", err)
return ""
}
h.setCookie(w, csrfCookieName, tok, int((365 * 24 * time.Hour).Seconds()))
return tok
}
func sessionToken(r *http.Request) string {
if c, err := r.Cookie(sessionCookieName); err == nil {
return c.Value
}
return ""
}
// startSessionCookie sets the browser's session cookie from a token IAM
// already minted (via Login/SignUp) — replaces Handlers.startSession,
// which used to create the session row itself; now IAM owns that.
func (h *Handlers) startSessionCookie(w http.ResponseWriter, rawToken string, expiresAt time.Time) {
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge <= 0 {
maxAge = int(sessionTTL.Seconds())
}
h.setCookie(w, sessionCookieName, rawToken, maxAge)
}
// ---------------------------------------------------------------------
// Small shared helpers — unchanged from the monolith
// ---------------------------------------------------------------------
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func normalizeEmail(raw string) (string, error) {
s := strings.ToLower(strings.TrimSpace(raw))
if s == "" || len(s) > maxEmailLen {
return "", errors.New("invalid email")
}
addr, err := mail.ParseAddress(s)
if err != nil || addr.Address != s {
return "", errors.New("invalid email")
}
return s, nil
}
func isSixDigitCode(raw string) bool {
if len(raw) != 6 {
return false
}
for _, c := range raw {
if c < '0' || c > '9' {
return false
}
}
return true
}
// Sentinel errors for validatePassword, so a caller (auth.go's
// translatePasswordError) can switch on *which* rule failed with
// errors.Is instead of matching against the English wording of an
// error string — the old approach, which silently breaks the moment
// either message or the min/max constants change.
var (
errPasswordTooShort = errors.New("password too short")
errPasswordTooLong = errors.New("password too long")
errPasswordMismatch = errors.New("passwords don't match")
)
func validatePassword(password, confirm string) error {
switch {
case len(password) < minPasswordLen:
return errPasswordTooShort
case len(password) > maxPasswordLen:
return errPasswordTooLong
case password != confirm:
return errPasswordMismatch
}
return nil
}
func parseForm(w http.ResponseWriter, r *http.Request) error {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
return r.ParseForm()
}
func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }

View File

@@ -0,0 +1,312 @@
// Package handlers (deliverables.go) replaces
// internal/handlers/deliverables.go. Beyond the original change
// (fileDeliverable's os.Stat call becoming a Workspace.StatFile gRPC
// call), producedPath now also recognizes the nine office-document
// tools (create_document/read_document/edit_document,
// create_spreadsheet/read_spreadsheet/edit_spreadsheet,
// create_presentation/read_presentation/edit_presentation —
// toolexec/office.go) in addition to the original
// write_file/edit_file/multi_edit_file. Those tools were added
// without a matching update here: their success messages ("created
// report.docx (4 paragraph(s), 512 bytes)", "wrote 3 cell(s) to
// budget.xlsx", "added 1 slide to deck.pptx", ...) never matched
// writeResultRe/editResultRe, so officekit would build and upload a
// perfectly valid .docx/.xlsx/.pptx (confirmed by officekit's own
// tests) and the chat UI would still never show a download card for
// it — the file existed in the workspace but was never surfaced as a
// deliverable. officeResultPathRe below matches on the one thing
// every one of those success messages reliably contains — the
// workspace path itself, recognizable by its .docx/.xlsx/.pptx
// extension — rather than each operation's own differently-worded
// phrasing, so it doesn't need one regex per operation and won't
// break again the next time an office tool's wording changes.
package handlers
import (
"context"
"html/template"
"path"
"regexp"
"strings"
agentv1 "enkly/gen/agent/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/renderkit"
)
const (
maxFileCards = 8
maxSnippetCards = 4
minSnippetLines = 3
)
type deliverableView struct {
Name string
TypeLabel string
Detail string
DownloadURL string
SnippetIndex int
}
var (
writeResultRe = regexp.MustCompile(`^wrote \d+ bytes to (.+)$`)
editResultRe = regexp.MustCompile(`^edited (.+?): (?:replaced|applied) `)
// officeResultPathRe pulls the workspace path out of any
// create_document/edit_document/create_spreadsheet/
// edit_spreadsheet/create_presentation/edit_presentation success
// message by matching the path itself (anything non-whitespace
// ending in .docx, .xlsx or .pptx) rather than the surrounding
// sentence, since office.go's nine tool handlers each phrase
// their result differently ("created %s (...)", "appended 1
// paragraph to %s", "wrote %d cell(s) to %s", "added 1 slide to
// %s", "updated slide %d of %s", ...) and every one of those
// phrasings always includes exactly that path.
officeResultPathRe = regexp.MustCompile(`\S+\.(?:docx|xlsx|pptx)\b`)
// officeDocTools is every office-document tool whose success
// message should be checked against officeResultPathRe.
// read_document/read_spreadsheet/read_presentation are excluded
// deliberately: those tools don't produce or change a file, so
// offering a download card for one would be misleading (and
// read_document's/read_spreadsheet's own result text can
// legitimately quote a .docx/.xlsx path from inside the document
// being read, which officeResultPathRe would otherwise mismatch
// as something just produced).
officeDocTools = map[string]bool{
"create_document": true,
"edit_document": true,
"create_spreadsheet": true,
"edit_spreadsheet": true,
"create_presentation": true,
"edit_presentation": true,
}
)
// producedPath replaces handlers.producedPath.
func producedPath(toolName, content string) string {
first, _, _ := strings.Cut(content, "\n")
if officeDocTools[toolName] {
return officeResultPathRe.FindString(first)
}
var m []string
switch toolName {
case "write_file":
m = writeResultRe.FindStringSubmatch(first)
case "edit_file", "multi_edit_file":
m = editResultRe.FindStringSubmatch(first)
}
if m == nil {
return ""
}
return strings.TrimSpace(m[1])
}
// cleanWorkspacePath normalizes a tool-reported path the way
// fileDeliverable and deliverablesHTMLFull already did inline —
// factored out so liveDeliverable and remainingToolCalls (chat.go's
// per-tool-call live delivery) can dedupe against the exact same key.
func cleanWorkspacePath(rel string) string {
return strings.TrimPrefix(path.Clean("/"+rel), "/")
}
func (h *Handlers) fileDeliverable(ctx context.Context, rel string) (deliverableView, bool) {
clean := cleanWorkspacePath(rel)
if clean == "" {
return deliverableView{}, false
}
stat, err := h.Workspace.StatFile(ctx, &workspacev1.StatFileRequest{Path: clean})
if err != nil || !stat.GetExists() || stat.GetIsDir() {
return deliverableView{}, false
}
name := path.Base(clean)
label := strings.ToUpper(strings.TrimPrefix(path.Ext(name), "."))
if label == "" {
label = "FILE"
}
return deliverableView{
Name: name, TypeLabel: label, Detail: humanBytes(stat.GetSize()), DownloadURL: downloadURLFor(clean),
}, true
}
// deliverablesHTML renders the card strip for the given produced paths
// and snippets, or "" when there's nothing worth showing.
func (h *Handlers) deliverablesHTMLFull(ctx context.Context, paths []string, snippets []renderkit.Snippet) string {
var views []deliverableView
seen := map[string]bool{}
for _, p := range paths {
key := cleanWorkspacePath(p)
if seen[key] {
continue
}
seen[key] = true
if v, ok := h.fileDeliverable(ctx, p); ok {
views = append(views, v)
if len(views) == maxFileCards {
break
}
}
}
var picked []renderkit.Snippet
for _, sn := range snippets {
if sn.Lines >= minSnippetLines && len(picked) < maxSnippetCards {
picked = append(picked, sn)
}
}
for n, sn := range picked {
name := "snippet" + sn.Ext
if len(picked) > 1 {
name = "snippet-" + itoa(n+1) + sn.Ext
}
if sn.Filename != "" {
name = sn.Filename
}
views = append(views, deliverableView{
Name: name, TypeLabel: sn.Label, Detail: itoa(sn.Lines) + " lines", SnippetIndex: sn.Index,
})
}
if len(views) == 0 {
return ""
}
return h.renderString("deliverables", views)
}
// deliverablesHTML is the entry point chat.go's StreamChat calls on the
// "done" event: every tool call made during the turn becomes a set of
// produced-file candidates (producedPath filters down to the
// successful write_file/edit_file/multi_edit_file ones).
func (h *Handlers) deliverablesHTML(ctx context.Context, toolCalls []*agentv1.ToolCall) string {
var paths []string
for _, tc := range toolCalls {
if tc.GetIsError() {
continue
}
if p := producedPath(tc.GetName(), tc.GetResult()); p != "" {
paths = append(paths, p)
}
}
return h.deliverablesHTMLFull(ctx, paths, nil)
}
// liveDeliverable checks whether tc — a tool call that just finished,
// mid-turn — produced or changed a file worth a download card, and
// one that hasn't already been shown live earlier in this same turn
// (shown, keyed by cleanWorkspacePath, is the running set chat.go's
// StreamChat keeps across the whole stream). This is what makes a
// document's download button appear as soon as the tool call that
// created it completes — see chat.go's TurnEvent_ToolCall case —
// rather than only once the whole turn finishes and the "done" event
// fires, which could be much later if the agent keeps working after
// creating the file. ok is false when there's nothing new to show: no
// path, an error result, a path already shown, or (StatFile coming
// back empty) a path that doesn't actually resolve to a file yet.
func (h *Handlers) liveDeliverable(ctx context.Context, streamID string, tc *agentv1.ToolCall, shown map[string]bool) (frag, key string, ok bool) {
if tc.GetIsError() {
return "", "", false
}
p := producedPath(tc.GetName(), tc.GetResult())
if p == "" {
return "", "", false
}
key = cleanWorkspacePath(p)
if shown[key] {
return "", "", false
}
v, exists := h.fileDeliverable(ctx, p)
if !exists {
return "", "", false
}
return h.renderString("deliverable_oob", struct {
StreamID string
Views []deliverableView
}{StreamID: streamID, Views: []deliverableView{v}}), key, true
}
// remainingToolCalls filters toolCalls down to the ones not already
// covered by shown, so chat.go's "done" event — which otherwise
// re-derives deliverable cards from every tool call of the whole turn
// — doesn't render a second, duplicate card for a file whose card
// already streamed live via liveDeliverable. On a resumed stream
// (StreamTurn's ResumeFromToolIndex), shown starts empty for the new
// connection, so a file created before the client reconnected still
// gets its card here as a fallback — nothing is ever silently lost,
// only de-duplicated on the common, unbroken-connection path.
func remainingToolCalls(toolCalls []*agentv1.ToolCall, shown map[string]bool) []*agentv1.ToolCall {
if len(shown) == 0 {
return toolCalls
}
out := make([]*agentv1.ToolCall, 0, len(toolCalls))
for _, tc := range toolCalls {
if !tc.GetIsError() {
if p := producedPath(tc.GetName(), tc.GetResult()); p != "" && shown[cleanWorkspacePath(p)] {
continue
}
}
out = append(out, tc)
}
return out
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// withDeliverables replaces Handlers.withDeliverables +
// Handlers.attachDeliverables: appends download cards to already-
// rendered messages when a conversation page is loaded.
func (h *Handlers) withDeliverables(ctx context.Context, msgs []messageView) []messageView {
h.attachDeliverables(ctx, msgs)
return msgs
}
func (h *Handlers) attachDeliverables(ctx context.Context, msgs []messageView) {
var (
pending []string
last = -1
)
flush := func() {
if last >= 0 && len(pending) > 0 {
if cards := h.deliverablesHTMLFull(ctx, pending, nil); cards != "" {
msgs[last].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template
}
}
pending, last = nil, -1
}
for i := range msgs {
switch msgs[i].Role {
case "user":
flush()
case "tool":
if p := producedPath(msgs[i].ToolName, msgs[i].Content); p != "" {
pending = append(pending, p)
}
case "assistant":
if cards := h.deliverablesHTMLFull(ctx, nil, renderkit.Snippets(msgs[i].Content)); cards != "" {
msgs[i].RenderedHTML += template.HTML(cards) // #nosec G203 -- rendered by html/template
}
last = i
}
}
flush()
}

View File

@@ -0,0 +1,272 @@
// Package handlers (files.go) replaces internal/handlers/files.go.
// Every path that used to go through llm.Toolbox.ResolvePath on the
// local filesystem now goes through the workspace service's gRPC API
// instead (UploadFile/DownloadFile/Tree) — that service applies the
// exact same jail (see services/workspace/internal/infrastructure/
// sandboxfs/sandbox.go), just on the other end of the wire now.
package handlers
import (
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/authsupport"
)
// officeContentTypes covers the handful of extensions whose correct
// MIME type Go's own mime.TypeByExtension can't always be trusted to
// know (it falls back to the host OS's registered MIME database,
// which doesn't universally list the OOXML types) — most importantly
// pkg/officekit's own .docx/.xlsx/.pptx output, which would otherwise
// sniff as "application/zip" (see downloadContentType).
var officeContentTypes = map[string]string{
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".pdf": "application/pdf",
}
// downloadContentType returns the Content-Type DownloadFile should
// advertise for name, by extension: officeContentTypes' explicit
// entries first, then mime.TypeByExtension, then a generic binary
// fallback — never server-side content sniffing (see the comment at
// DownloadFile's w.Header().Set("Content-Type", ...) call for why).
func downloadContentType(name string) string {
ext := strings.ToLower(filepath.Ext(name))
if ct, ok := officeContentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
const (
maxUploadFileBytes = 20 << 20
maxUploadFilesPerRequest = 8
maxUploadRequestBytes = maxUploadFilesPerRequest*maxUploadFileBytes + (1 << 20)
maxUploadMemory = 10 << 20
uploadFormField = "files"
)
type workspaceFileView struct {
Path string
DownloadURL string
Size int64
SizeHuman string
ModTime time.Time
}
type filesPanelData struct {
WorkspaceFiles []workspaceFileView
ConversationID int64
CSRFToken string
UploadMessage string
UploadError string
}
// ---------------------------------------------------------------------
// GET /files, POST /files/upload
// ---------------------------------------------------------------------
func (h *Handlers) FilesPanel(w http.ResponseWriter, r *http.Request) {
convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64)
h.renderFilesPanel(w, r, filesPanelData{ConversationID: convID})
}
func (h *Handlers) UploadFile(w http.ResponseWriter, r *http.Request) {
convID, _ := strconv.ParseInt(r.PostFormValue("conversation_id"), 10, 64)
data := filesPanelData{ConversationID: convID}
destDir := strings.TrimSpace(r.PostFormValue("dir"))
var headers []*multipart.FileHeader
if r.MultipartForm != nil {
headers = r.MultipartForm.File[uploadFormField]
}
switch {
case len(headers) == 0:
data.UploadError = "Choose at least one file first."
case len(headers) > maxUploadFilesPerRequest:
data.UploadError = fmt.Sprintf("Too many files in one upload (limit %d).", maxUploadFilesPerRequest)
default:
saved, err := h.saveUploadedFiles(r, destDir, headers)
if err != nil {
data.UploadError = err.Error()
} else if len(saved) == 1 {
data.UploadMessage = fmt.Sprintf("Uploaded %s.", saved[0])
} else {
data.UploadMessage = fmt.Sprintf("Uploaded %d files.", len(saved))
}
}
h.renderFilesPanel(w, r, data)
}
func (h *Handlers) saveUploadedFiles(r *http.Request, destDir string, headers []*multipart.FileHeader) ([]string, error) {
saved := make([]string, 0, len(headers))
for _, fh := range headers {
name, err := h.saveUploadedFile(r, destDir, fh)
if err != nil {
return saved, err
}
saved = append(saved, name)
}
return saved, nil
}
func (h *Handlers) saveUploadedFile(r *http.Request, destDir string, fh *multipart.FileHeader) (string, error) {
rel, err := uploadDestPath(destDir, fh.Filename)
if err != nil {
return "", err
}
return h.writeUploadedFile(r, rel, fh)
}
// uploadDestPath replaces the browser-supplied filename with just its
// base name (stripping any directory component, which browsers
// sometimes still send for webkitdirectory-style selections) and
// joins it onto destDir. Split out of saveUploadedFile so
// attachments.go's saveChatAttachment can compute a destination,
// de-duplicate it against what's already in the workspace, and only
// then write — see uniqueWorkspacePath.
func uploadDestPath(destDir, filename string) (string, error) {
base := filepath.Base(filepath.ToSlash(filename))
if base == "" || base == "." || base == "/" {
return "", fmt.Errorf("a file has no usable name")
}
if destDir == "" {
return base, nil
}
return path.Join(destDir, base), nil
}
// writeUploadedFile replaces the second half of the old
// saveUploadedFile: given an already-computed destination path, reads
// and size-checks the multipart part and writes it into the workspace.
func (h *Handlers) writeUploadedFile(r *http.Request, rel string, fh *multipart.FileHeader) (string, error) {
if fh.Size > maxUploadFileBytes {
return "", fmt.Errorf("%q is %s, larger than the %s-per-file limit", fh.Filename, humanBytes(fh.Size), humanBytes(maxUploadFileBytes))
}
src, err := fh.Open()
if err != nil {
return "", fmt.Errorf("%q: %w", fh.Filename, err)
}
defer src.Close()
content, err := io.ReadAll(io.LimitReader(src, maxUploadFileBytes+1))
if err != nil {
return "", fmt.Errorf("%q: read: %w", rel, err)
}
if len(content) > maxUploadFileBytes {
return "", fmt.Errorf("%q exceeds the %s-per-file limit", rel, humanBytes(maxUploadFileBytes))
}
if _, err := h.Workspace.UploadFile(r.Context(), &workspacev1.UploadFileRequest{Path: rel, Content: content}); err != nil {
return "", fmt.Errorf("%q: %w", rel, err)
}
return filepath.ToSlash(rel), nil
}
func (h *Handlers) renderFilesPanel(w http.ResponseWriter, r *http.Request, data filesPanelData) {
files, err := h.listWorkspaceFilesCtx(r)
if err != nil {
h.Logger.Warn("list workspace files", "err", err)
}
data.WorkspaceFiles = files
data.CSRFToken = h.csrfToken(w, r)
h.render(w, http.StatusOK, "workspace_files_panel", data)
}
func (h *Handlers) listWorkspaceFilesCtx(r *http.Request) ([]workspaceFileView, error) {
resp, err := h.Workspace.Tree(r.Context(), &workspacev1.TreeRequest{OneLevel: false})
if err != nil {
return nil, err
}
out := make([]workspaceFileView, 0, len(resp.GetNodes()))
for _, n := range resp.GetNodes() {
out = append(out, workspaceFileView{
Path: n.GetRelPath(), DownloadURL: downloadURLFor(n.GetRelPath()),
Size: n.GetSize(), SizeHuman: humanBytes(n.GetSize()), ModTime: n.GetModifiedAt().AsTime(),
})
}
return out, nil
}
// ---------------------------------------------------------------------
// GET /files/download/{path...}
// ---------------------------------------------------------------------
func (h *Handlers) DownloadFile(w http.ResponseWriter, r *http.Request) {
rel := r.PathValue("path")
if strings.TrimSpace(rel) == "" {
http.NotFound(w, r)
return
}
resp, err := h.Workspace.DownloadFile(r.Context(), &workspacev1.DownloadFileRequest{Path: rel})
if err != nil {
http.NotFound(w, r)
return
}
name := resp.GetFilename()
if name == "" {
name = filepath.Base(rel)
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
w.Header().Set("X-Content-Type-Options", "nosniff")
// Content-Type is set explicitly — left unset, Go's server auto-
// sniffs it from the first 512 bytes the moment we Write() (see
// net/http's chunkWriter.writeHeader), independent of the
// X-Content-Type-Options header above (that only tells the
// browser not to second-guess whatever type the server already
// sent — it doesn't stop the server's own sniffing from running
// in the first place). A .docx/.xlsx/.pptx is, at the byte level,
// a zip archive (PK\x03\x04), so unsniffed it comes back as
// "application/zip" — technically not wrong, but not what Office
// expects either, and actively misleading for anything that
// pays attention to the advertised type rather than just the
// filename extension.
w.Header().Set("Content-Type", downloadContentType(name))
w.Header().Set("Content-Length", strconv.Itoa(len(resp.GetContent())))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(resp.GetContent())
}
// ---------------------------------------------------------------------
// CSRF for multipart uploads — replaces Handlers.RequireCSRFUpload.
// ---------------------------------------------------------------------
func (h *Handlers) RequireCSRFUpload(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestBytes)
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
http.Error(w, fmt.Sprintf("upload too large or malformed (limit %s total): %v", humanBytes(maxUploadRequestBytes), err), http.StatusBadRequest)
return
}
cookie, _ := r.Cookie(csrfCookieName)
var cookieVal string
if cookie != nil {
cookieVal = cookie.Value
}
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if !authsupport.VerifyCSRF(cookieVal, submitted) {
http.Error(w, "invalid or missing CSRF token — reload the page and try again", http.StatusForbidden)
return
}
next(w, r)
}
}

View File

@@ -0,0 +1,70 @@
// Package handlers (i18n.go) is the gateway's half of multilingual
// support: resolving which locale a given request should render in,
// and the /language switcher that lets a visitor override it. The
// catalog itself — supported locales, their strings, the T() lookup —
// lives in pkg/i18n so the agent service can share the same locale
// codes and display names (see agentrun.WithPreferredLanguage)
// without the two services depending on each other.
//
// This mirrors ui.go's theme toggle almost exactly on purpose: same
// cookie-based persistence, same safeNext redirect, same form-POST
// shape — a visitor who already knows how the theme switcher works
// recognizes the language switcher instantly, and there's only one
// pattern for a future settings toggle to copy.
package handlers
import (
"net/http"
"time"
"enkly/pkg/i18n"
)
const langCookieName = "enkly_lang"
// localeFrom resolves the locale a request should render in:
// 1. an explicit ?lang= query override (lets a shared/bookmarked link
// force a language, and is how SetLanguage's own redirect target
// can take effect without a second round trip),
// 2. the enkly_lang cookie, set the first time a visitor picks one
// (explicitly here, or implicitly — see SetLanguage),
// 3. the browser's Accept-Language header, for a first-time visitor
// who hasn't chosen anything yet,
// 4. i18n.Default ("en") if none of the above yield a supported
// locale.
//
// There is deliberately no account-level preference here: enkly has
// no per-user settings table in any service's schema today, and
// adding `preferred_language` to the IAM User message/row is a schema
// + generated-gRPC-code change of its own — see ARCHITECTURE.md's
// "Language preference storage" note for the follow-up. The cookie
// already survives logins, browser restarts and (for a year) long
// gaps between visits; it just doesn't follow a visitor to a second
// device the way an account field would.
func localeFrom(r *http.Request) i18n.Locale {
if q := r.URL.Query().Get("lang"); q != "" && i18n.IsSupported(q) {
return i18n.Locale(q)
}
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
return i18n.Locale(c.Value)
}
return i18n.NegotiateAcceptLanguage(r.Header.Get("Accept-Language"))
}
// SetLanguage (POST /language) replaces Handlers.SetTheme's role for
// language: persists the chosen locale in a year-long cookie and
// sends the browser back to wherever it came from. Rejects an
// unsupported code rather than silently falling back, the same way
// SetTheme rejects an unknown theme value — a bad value here is
// either a stale client (an old switcher built against a locale list
// that has since changed) or a tampered request, and either way the
// right response is a 400, not a cookie nobody asked for.
func (h *Handlers) SetLanguage(w http.ResponseWriter, r *http.Request) {
lang := r.PostFormValue("lang")
if !i18n.IsSupported(lang) {
http.Error(w, "unknown language", http.StatusBadRequest)
return
}
h.setCookie(w, langCookieName, lang, int((365 * 24 * time.Hour).Seconds()))
http.Redirect(w, r, safeNext(r.PostFormValue("next")), http.StatusSeeOther)
}

View File

@@ -0,0 +1,315 @@
package handlers
import (
"compress/gzip"
"log/slog"
"net/http"
"runtime/debug"
"strings"
"sync"
"time"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/authsupport"
)
// Middleware wraps an http.Handler.
type Middleware func(http.Handler) http.Handler
// Chain applies middlewares to h so that the first one listed is the
// outermost (runs first on the way in, last on the way out).
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// ---------------------------------------------------------------------
// Response-writer wrappers (status capture, Flush/Unwrap passthrough)
// ---------------------------------------------------------------------
type statusWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status == 0 {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
// Unwrap lets http.NewResponseController reach the real writer's
// Flush / SetWriteDeadline — essential for SSE, which flushes after every event.
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// ---------------------------------------------------------------------
// Recover
// ---------------------------------------------------------------------
// Recover turns a handler panic into a logged 500 instead of a dropped
// connection.
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler {
panic(rec) // net/http's own "abort this response" signal
}
logger.Error("panic in handler",
"method", r.Method, "path", r.URL.Path, "panic", rec, "stack", string(debug.Stack()))
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// ---------------------------------------------------------------------
// Request logging
// ---------------------------------------------------------------------
// RequestLogger logs one structured line per request. Static assets and
// health checks are logged at debug level so they don't drown real traffic.
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r)
status := sw.status
if status == 0 {
status = http.StatusOK
}
level := slog.LevelInfo
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/static/") {
level = slog.LevelDebug
}
logger.Log(r.Context(), level, "request",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"bytes", sw.bytes,
"dur", time.Since(start).Round(time.Millisecond),
"ip", clientIP(r),
)
})
}
}
// ---------------------------------------------------------------------
// Security headers
// ---------------------------------------------------------------------
// contentSecurityPolicy: everything same-origin. style-src allows inline
// styles (a couple of templates use style="…" and htmx may inject one);
// script-src does NOT allow inline or eval — the front end is vendored
// htmx + one same-origin script file, and index.html configures htmx with
// allowEval:false so nothing in it needs 'unsafe-eval'.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
// SecurityHeaders adds baseline browser hardening headers to every response.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hd := w.Header()
hd.Set("Content-Security-Policy", contentSecurityPolicy)
hd.Set("X-Content-Type-Options", "nosniff")
hd.Set("X-Frame-Options", "DENY")
hd.Set("Referrer-Policy", "same-origin")
hd.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
hd.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------
// Gzip
// ---------------------------------------------------------------------
var gzipPool = sync.Pool{New: func() any { return gzip.NewWriter(nil) }}
// gzipWriter compresses text-like responses. The decision is made
// lazily on the first write, when the Content-Type is known.
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
wroteHeader bool
status int
}
func compressible(ct string) bool {
ct = strings.ToLower(ct)
for _, p := range []string{
"text/", "application/json", "application/javascript", "application/xml", "image/svg+xml",
} {
if strings.HasPrefix(ct, p) {
return true
}
}
return false
}
func (w *gzipWriter) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.status = code
w.wroteHeader = true
// Decide now, so Content-Length can be dropped before the header goes out.
hd := w.Header()
if !w.decided {
w.decided = true
bodyless := code == http.StatusNoContent || code == http.StatusNotModified || code < 200
if !bodyless && hd.Get("Content-Encoding") == "" && compressible(hd.Get("Content-Type")) {
hd.Del("Content-Length")
hd.Set("Content-Encoding", "gzip")
w.gz = gzipPool.Get().(*gzip.Writer)
w.gz.Reset(w.ResponseWriter)
}
}
hd.Add("Vary", "Accept-Encoding")
w.ResponseWriter.WriteHeader(code)
}
func (w *gzipWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", http.DetectContentType(b))
}
w.WriteHeader(http.StatusOK)
}
if w.gz != nil {
return w.gz.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *gzipWriter) Flush() {
if w.gz != nil {
_ = w.gz.Flush()
}
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *gzipWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *gzipWriter) close() {
if w.gz != nil {
_ = w.gz.Close()
gzipPool.Put(w.gz)
w.gz = nil
}
}
// Gzip compresses responses for clients that accept it. It never touches
// the SSE stream endpoint (compression buffering would delay events), the
// file-download endpoint (arbitrary — often already-compressed — file
// bytes streamed via http.ServeContent, which also needs to serve
// uncompressed byte ranges), and skips Range requests generally (static
// file partial content).
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") ||
strings.HasPrefix(r.URL.Path, "/chat/stream/") ||
strings.HasPrefix(r.URL.Path, "/files/download/") ||
strings.Contains(r.Header.Get("Accept"), "text/event-stream") ||
r.Header.Get("Range") != "" {
next.ServeHTTP(w, r)
return
}
gw := &gzipWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
// ---------------------------------------------------------------------
// Auth & CSRF guards (handler-level, used per route in main.go)
// ---------------------------------------------------------------------
// RequireAuth replaces Handlers.RequireAuth: resolves the session
// cookie to a user via one IAM.ValidateSession gRPC call and puts it on
// the request context; otherwise it sends the browser to /login (htmx
// requests get an HX-Redirect so the whole page navigates, rather than
// the login form being swapped into a chat fragment). This is now the
// one RPC on the hot path of nearly every request — see
// ARCHITECTURE.md's "Session validation latency" note.
func (h *Handlers) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if raw := sessionToken(r); raw != "" {
resp, err := h.IAM.ValidateSession(r.Context(), &iamv1.ValidateSessionRequest{SessionToken: raw})
switch {
case err == nil && resp.GetValid() && resp.GetUser().GetEmailVerified():
next(w, r.WithContext(withUser(r.Context(), resp.GetUser())))
return
case err != nil:
h.Logger.Error("validate session", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
if isHTMX(r) {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
// RequireCSRF rejects a state-changing request unless its csrf_token
// form field (or X-CSRF-Token header) matches the csrf cookie. Purely
// an edge concern — unchanged from the monolith.
func (h *Handlers) RequireCSRF(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := parseForm(w, r); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
cookie, _ := r.Cookie(csrfCookieName)
var cookieVal string
if cookie != nil {
cookieVal = cookie.Value
}
submitted := r.PostFormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if !authsupport.VerifyCSRF(cookieVal, submitted) {
http.Error(w, "invalid or missing CSRF token — reload the page and try again", http.StatusForbidden)
return
}
next(w, r)
}
}
// RequireCSRFUpload lives in files.go (it needs the upload size
// constants defined there).

View File

@@ -0,0 +1,324 @@
// Package handlers (pages.go) replaces internal/handlers/pages.go: the
// main chat page and conversation management. Every h.DB call becomes
// one gRPC call to the conversation service (plus billing for the plan
// badge, knowledge for the indexed-files list, and workspace for the
// files panel).
package handlers
import (
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
knowledgev1 "enkly/gen/knowledge/v1"
"enkly/pkg/renderkit"
)
// StreamView is what the "assistant_stream" template needs to attach a
// live assistant bubble to an in-flight run.
type StreamView struct {
StreamID string
CSRFToken string
}
// messageView is a conversation message plus its rendered HTML — the
// gateway's local equivalent of the monolith's models.Message (which
// carried a RenderedHTML field filled in by prepareMessages).
type messageView struct {
Role string
ToolName string
Content string // raw text, kept alongside RenderedHTML for deliverables.go's producedPath
RenderedHTML template.HTML
}
// conversationView is the gateway's local equivalent of
// models.Conversation, just the fields index.html actually uses.
type conversationView struct {
ID int64
Title string
}
// indexData is the template data for index.html — identical field set
// to the monolith's indexData.
type indexData struct {
ConversationTitle string
ConversationID int64
Conversations []conversationView
Messages []messageView
Files []string
CSRFToken string
UserEmail string
Theme string
Lang string
PlanName string
ActiveStream *StreamView
WorkspaceFiles []workspaceFileView
UploadMessage string
UploadError string
}
// Index (GET /) opens the user's most recently active conversation,
// creating the first one if they have none.
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx := r.Context()
conv, err := h.Conversation.MostRecentConversation(ctx, &conversationv1.MostRecentConversationRequest{UserId: user.GetId()})
if isNotFound(err) {
conv, err = h.Conversation.CreateConversation(ctx, &conversationv1.CreateConversationRequest{UserId: user.GetId()})
}
if err != nil {
h.serverError(w, "open conversation", err)
return
}
h.renderConversation(w, r, conv)
}
// SwitchConversation (GET /c/{id}) shows a specific conversation.
func (h *Handlers) SwitchConversation(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
conv, err := h.Conversation.GetConversation(r.Context(), &conversationv1.GetConversationRequest{UserId: user.GetId(), ConversationId: id})
if isNotFound(err) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get conversation", err)
return
}
h.renderConversation(w, r, conv)
}
func isNotFound(err error) bool {
if err == nil {
return false
}
st, ok := status.FromError(err)
return ok && st.Code() == codes.NotFound
}
func (h *Handlers) renderConversation(w http.ResponseWriter, r *http.Request, conv *conversationv1.Conversation) {
ctx := r.Context()
user := userFrom(r)
convsResp, err := h.Conversation.ListConversations(ctx, &conversationv1.ListConversationsRequest{UserId: user.GetId()})
if err != nil {
h.serverError(w, "list conversations", err)
return
}
msgsResp, err := h.Conversation.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: conv.GetId()})
if err != nil {
h.serverError(w, "list messages", err)
return
}
var files []string
if resp, err := h.Knowledge.ListIndexedFiles(ctx, &knowledgev1.ListIndexedFilesRequest{Limit: 200}); err != nil {
h.Logger.Warn("list indexed files", "err", err)
} else {
files = resp.GetFilePaths()
}
workspaceFiles, err := h.listWorkspaceFilesCtx(r)
if err != nil {
h.Logger.Warn("list workspace files", "err", err)
}
planName := "Free"
if sub, err := h.Billing.GetOrCreateSubscription(ctx, &billingv1.GetOrCreateSubscriptionRequest{UserId: user.GetId()}); err != nil {
h.Logger.Warn("load subscription", "err", err)
} else {
planName = planDisplayName(sub.GetPlan())
}
convs := make([]conversationView, 0, len(convsResp.GetConversations()))
for _, c := range convsResp.GetConversations() {
convs = append(convs, conversationView{ID: c.GetId(), Title: c.GetTitle()})
}
csrf := h.csrfToken(w, r)
data := indexData{
ConversationTitle: conv.GetTitle(),
ConversationID: conv.GetId(),
Conversations: convs,
Messages: h.withDeliverables(ctx, h.prepareMessages(msgsResp.GetMessages())),
Files: files,
CSRFToken: csrf,
UserEmail: user.GetEmail(),
Theme: themeFrom(r),
Lang: string(localeFrom(r)),
PlanName: planName,
WorkspaceFiles: workspaceFiles,
}
// NOTE: reattaching to a still-in-flight run after a page reload
// (the monolith's h.Streams.activeFor lookup) is intentionally not
// wired here — it would need a new "is there an active stream for
// this conversation" agent RPC with no use case elsewhere in the
// API. The reply still completes and is persisted normally; a
// reload mid-reply just shows it on the next load instead of
// resuming the live bubble. See ARCHITECTURE.md's known gaps.
h.render(w, http.StatusOK, "index", data)
}
// NewConversation (POST /conversations/new).
func (h *Handlers) NewConversation(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx := r.Context()
if latest, err := h.Conversation.MostRecentConversation(ctx, &conversationv1.MostRecentConversationRequest{UserId: user.GetId()}); err == nil {
if n, err := h.Conversation.CountMessages(ctx, &conversationv1.CountMessagesRequest{ConversationId: latest.GetId()}); err == nil && n.GetCount() == 0 {
http.Redirect(w, r, fmt.Sprintf("/c/%d", latest.GetId()), http.StatusSeeOther)
return
}
}
conv, err := h.Conversation.CreateConversation(ctx, &conversationv1.CreateConversationRequest{UserId: user.GetId()})
if err != nil {
h.serverError(w, "create conversation", err)
return
}
http.Redirect(w, r, fmt.Sprintf("/c/%d", conv.GetId()), http.StatusSeeOther)
}
// RenameConversation (POST /conversations/{id}/rename).
func (h *Handlers) RenameConversation(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
title := cleanTitle(r.PostFormValue("title"), maxTitleChars)
if title == "" {
http.Redirect(w, r, fmt.Sprintf("/c/%d", id), http.StatusSeeOther)
return
}
_, err = h.Conversation.RenameConversation(r.Context(), &conversationv1.RenameConversationRequest{UserId: user.GetId(), ConversationId: id, Title: title})
switch {
case err == nil:
http.Redirect(w, r, fmt.Sprintf("/c/%d", id), http.StatusSeeOther)
case isNotFound(err):
http.NotFound(w, r)
default:
h.serverError(w, "rename conversation", err)
}
}
// DeleteConversationHandler (POST /conversations/{id}/delete).
func (h *Handlers) DeleteConversationHandler(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
_, err = h.Conversation.DeleteConversation(r.Context(), &conversationv1.DeleteConversationRequest{UserId: user.GetId(), ConversationId: id})
switch {
case err == nil:
// NOTE: the monolith also cancelled any in-flight run on this
// conversation here (h.Streams.cancelConversation), so a
// stream still writing wouldn't hit AddMessage failures on a
// deleted row. Agent's API has no "stop by conversation" RPC
// (only by stream_id — see agent.proto), so that cleanup is
// not wired here; a straggling run's writes will simply fail
// and be logged by the agent service instead of being
// pre-empted. See ARCHITECTURE.md's known gaps.
http.Redirect(w, r, "/", http.StatusSeeOther)
case isNotFound(err):
http.NotFound(w, r)
default:
h.serverError(w, "delete conversation", err)
}
}
func (h *Handlers) serverError(w http.ResponseWriter, what string, err error) {
h.Logger.Error(what, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
// ---------------------------------------------------------------------
// View helpers
// ---------------------------------------------------------------------
const maxToolBodyChars = 12_000
// prepareMessages fills RenderedHTML for template rendering: Markdown
// for assistant replies, escaped text (plus any attachment chips —
// see attachments.go's renderUserMessage) for user prompts, and an
// escaped <pre> for tool output. Identical logic to the monolith's,
// now building local messageView values instead of mutating
// models.Message.
func (h *Handlers) prepareMessages(msgs []*conversationv1.Message) []messageView {
out := make([]messageView, len(msgs))
for i, m := range msgs {
mv := messageView{Role: roleString(m.GetRole()), ToolName: m.GetToolName(), Content: m.GetContent()}
switch m.GetRole() {
case conversationv1.Role_ROLE_ASSISTANT:
mv.RenderedHTML = template.HTML(renderkit.Markdown(m.GetContent())) // #nosec G203 -- sanitized by renderkit
case conversationv1.Role_ROLE_TOOL:
mv.RenderedHTML = toolBodyHTML(m.GetContent())
default:
mv.RenderedHTML = h.renderUserMessage(m.GetContent())
}
out[i] = mv
}
return out
}
func roleString(r conversationv1.Role) string {
switch r {
case conversationv1.Role_ROLE_USER:
return "user"
case conversationv1.Role_ROLE_ASSISTANT:
return "assistant"
case conversationv1.Role_ROLE_TOOL:
return "tool"
default:
return "system"
}
}
func toolBodyHTML(content string) template.HTML {
if strings.TrimSpace(content) == "" {
return template.HTML(`<pre class="tool-output">(no output)</pre>`)
}
if len(content) > maxToolBodyChars {
cut := maxToolBodyChars
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
content = content[:cut] + fmt.Sprintf("\n… [%d more characters not shown]", len(content)-cut)
}
return template.HTML(`<pre class="tool-output">` + template.HTMLEscapeString(content) + `</pre>`) // #nosec G203 -- escaped
}
func cleanTitle(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return strings.TrimSpace(string(runes[:max-1])) + "…"
}
func planDisplayName(p billingv1.Plan) string {
switch p {
case billingv1.Plan_PLAN_BASIC:
return "Basic"
case billingv1.Plan_PLAN_PRO:
return "Pro"
case billingv1.Plan_PLAN_PREMIUM:
return "Premium"
default:
return "Free"
}
}

View File

@@ -0,0 +1,187 @@
// Package handlers (ui.go) replaces internal/handlers/ui.go: the theme
// toggle, the lazy-expanding file explorer tree, and the status bar.
// Where the original read the local filesystem directly, this calls
// workspace's Tree RPC (one_level=true for a single folder's children,
// matching the original's per-click round trip exactly).
package handlers
import (
"context"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
conversationv1 "enkly/gen/conversation/v1"
workspacev1 "enkly/gen/workspace/v1"
)
const themeCookieName = "enkly_theme"
func themeFrom(r *http.Request) string {
c, err := r.Cookie(themeCookieName)
if err != nil {
return ""
}
if c.Value == "light" || c.Value == "dark" {
return c.Value
}
return ""
}
func safeNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") {
return "/"
}
return next
}
func (h *Handlers) SetTheme(w http.ResponseWriter, r *http.Request) {
switch r.PostFormValue("theme") {
case "light":
h.setCookie(w, themeCookieName, "light", int((365 * 24 * time.Hour).Seconds()))
case "dark":
h.setCookie(w, themeCookieName, "dark", int((365 * 24 * time.Hour).Seconds()))
case "auto":
h.setCookie(w, themeCookieName, "", -1)
default:
http.Error(w, "unknown theme", http.StatusBadRequest)
return
}
http.Redirect(w, r, safeNext(r.PostFormValue("next")), http.StatusSeeOther)
}
// ---------------------------------------------------------------------
// File explorer (lazy-expanding tree) — replaces Handlers.FileTree.
// ---------------------------------------------------------------------
type treeNode struct {
Name string
Path string
IsDir bool
Open bool
Children []treeNode
ExpandURL string
CollapseURL string
DownloadURL string
SizeHuman string
}
func treeURL(rel string, open bool) string {
o := "1"
if !open {
o = "0"
}
return "/tree?path=" + url.QueryEscape(rel) + "&open=" + o
}
// downloadURLFor replaces handlers.downloadURLFor: percent-encodes each
// path segment individually (so a filename with spaces, '#', '?', or
// non-ASCII characters still produces a valid, correctly round-tripping
// link) while leaving the '/' separators between segments alone.
func downloadURLFor(rel string) string {
segments := strings.Split(rel, "/")
for i, s := range segments {
segments[i] = url.PathEscape(s)
}
return "/files/download/" + strings.Join(segments, "/")
}
// humanBytes replaces handlers.humanBytes.
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + " B"
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + "KMGTPE"[exp:exp+1] + "iB"
}
func (h *Handlers) FileTree(w http.ResponseWriter, r *http.Request) {
rel := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
resp, err := h.Workspace.Tree(r.Context(), &workspacev1.TreeRequest{Path: rel, OneLevel: true})
if err != nil {
http.NotFound(w, r)
return
}
kids := make([]treeNode, 0, len(resp.GetNodes()))
for _, n := range resp.GetNodes() {
tn := treeNode{Name: n.GetName(), Path: n.GetRelPath(), IsDir: n.GetIsDir()}
if n.GetIsDir() {
tn.ExpandURL = treeURL(n.GetRelPath(), true)
} else {
tn.DownloadURL = downloadURLFor(n.GetRelPath())
tn.SizeHuman = humanBytes(n.GetSize())
}
kids = append(kids, tn)
}
if rel == "" {
h.render(w, http.StatusOK, "tree_root", treeNode{Children: kids})
return
}
node := treeNode{
Name: path.Base(rel), Path: rel, IsDir: true,
Open: r.URL.Query().Get("open") != "0",
ExpandURL: treeURL(rel, true),
CollapseURL: treeURL(rel, false),
}
if node.Open {
node.Children = kids
}
h.render(w, http.StatusOK, "tree_dir", node)
}
// ---------------------------------------------------------------------
// Status bar — replaces Handlers.StatusBar.
// ---------------------------------------------------------------------
type statusData struct {
ConversationID int64
Model string
PostgresOK bool
VLLMOK bool
ContextTokens int
ContextLimit int
ContextPct int
}
func estimateTokens(chars int) int { return (chars + 3) / 4 }
func (h *Handlers) StatusBar(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64)
//data := statusData{ConversationID: convID, Model: "qwen3.5"}
data := statusData{ConversationID: convID, Model: "Baobab 1"}
// PostgresOK now stands in for "the conversation service (and
// hence its Postgres) is reachable" — the gateway itself no longer
// holds a DB handle to ping directly.
if _, err := h.Conversation.CountMessages(ctx, &conversationv1.CountMessagesRequest{ConversationId: convID}); err == nil {
data.PostgresOK = true
}
if convID != 0 && user != nil {
if msgs, err := h.Conversation.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: convID}); err == nil {
total := 0
for _, m := range msgs.GetMessages() {
total += len(m.GetContent())
}
data.ContextTokens = estimateTokens(total)
}
}
data.VLLMOK = true // see ARCHITECTURE.md: the gateway has no direct vLLM reachability check post-split; agent/knowledge each probe their own chat/embed servers.
h.render(w, http.StatusOK, "status_bar", data)
}

View File

@@ -0,0 +1,950 @@
/* =====================================================================
enkly — style.css (v2 design system)
Vanilla CSS, no build step, system fonts only (the gateway's CSP is
same-origin, so nothing here loads from a CDN).
Direction: a calm ink-blue workspace with ONE luminous element — the
composer. Everything else is quiet glass: hairline borders, soft
surfaces, no decorative gradients. Colour carries meaning (tool kind,
status), never decoration.
Contents
1. Tokens (dark default, light override at the bottom of section 1)
2. Base + ambient background
3. Sidebar
4. Top bar + command palette
5. Chat window + empty state (hero, starters, tools strip)
6. Messages, thinking, tool steps
7. Composer (the glow ring) + attachments
8. Mobile
9+ Auth, billing, tree, status, syntax, deliverables, drop zone
===================================================================== */
/* ---------------------------------------------------------------------
1. Tokens
--------------------------------------------------------------------- */
:root {
/* surfaces: ink blue, three steps */
--bg: #070b14;
--bg-elev: #0c1220;
--bg-elev-2: #111a2e;
--bg-hover: #17223b;
--glass: rgba(14, 21, 38, 0.72);
--glass-strong: rgba(18, 27, 48, 0.88);
--border: #1a2540;
--border-strong: #26355a;
--border-glass: rgba(120, 150, 255, 0.14);
--text: #e9edf7;
--text-dim: #a3adc4;
--text-faint: #6b7794;
/* brand: the logo's teal, plus a cool blue it fades into */
--accent: #2dd4bf;
--accent-2: #5b8cff;
--accent-soft: rgba(45, 212, 191, 0.13);
--accent-ring: linear-gradient(120deg, #2dd4bf 0%, #5b8cff 55%, #8b7bff 100%);
--on-accent: #041218;
--ok: #4ade80;
--warn: #fbbf24;
--danger: #f87171;
/* tool kinds (used by step cards) */
--k-read: #60a5fa;
--k-write: #fbbf24;
--k-search: #a78bfa;
--k-run: #4ade80;
--k-plan: #2dd4bf;
--k-agent: #f472b6;
--radius: 12px;
--radius-lg: 18px;
--sidebar-w: 292px;
--shadow-glow: 0 0 0 1px rgba(91, 140, 255, 0.35), 0 12px 50px -12px rgba(61, 120, 255, 0.55), 0 0 90px -30px rgba(45, 212, 191, 0.5);
--shadow-card: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 10px 30px -18px rgba(0, 0, 0, 0.7);
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* syntax tokens (also referenced by section 9) */
--tok-kw: #c4a7ff;
--tok-str: #86efac;
--tok-cmt: #6b7794;
--tok-num: #fcd34d;
--tok-fn: #7cb0ff;
--tok-type: #5eead4;
--tok-const: #fdba74;
--tok-var: #fde68a;
--tok-add: #4ade80;
--tok-del: #f87171;
--tok-hunk: #7cb0ff;
/* tool glyphs: tiny inline SVGs used as CSS masks (data: is allowed by the CSP) */
--i-file: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z'/><path d='M14 3v5h5M9 13h6M9 17h4'/></svg>");
--i-edit: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M12 20h9'/><path d='M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z'/></svg>");
--i-folder: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'/></svg>");
--i-search: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>");
--i-term: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m4 17 6-6-6-6M12 19h8'/></svg>");
--i-todo: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='4' width='18' height='16' rx='3'/><path d='m8 12 3 3 5-6'/></svg>");
--i-spark: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'><path d='M12 2.5c.35 0 .66.23.76.57l1.4 4.77 4.77 1.4a.79.79 0 0 1 0 1.52l-4.77 1.4-1.4 4.77a.79.79 0 0 1-1.52 0l-1.4-4.77-4.77-1.4a.79.79 0 0 1 0-1.52l4.77-1.4 1.4-4.77c.1-.34.41-.57.76-.57Z'/><path d='M19 15.2c.24 0 .45.16.52.39l.5 1.7 1.7.5a.55.55 0 0 1 0 1.05l-1.7.5-.5 1.7a.55.55 0 0 1-1.05 0l-.5-1.7-1.7-.5a.55.55 0 0 1 0-1.05l1.7-.5.5-1.7a.55.55 0 0 1 .53-.39Z'/></svg>");
--i-cog: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='3'/><path d='M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1'/></svg>");
}
/* Light theme: same hierarchy, paper-white surfaces, deeper accents so
text stays above 4.5:1. */
:root[data-theme="light"] {
--bg: #f5f7fc;
--bg-elev: #ffffff;
--bg-elev-2: #eef2fa;
--bg-hover: #e5ebf7;
--glass: rgba(255, 255, 255, 0.78);
--glass-strong: rgba(255, 255, 255, 0.92);
--border: #e0e6f1;
--border-strong: #c9d3e6;
--border-glass: rgba(40, 70, 160, 0.14);
--text: #0e1526;
--text-dim: #475169;
--text-faint: #76809a;
--accent: #0b7f78;
--accent-2: #2f62e0;
--accent-soft: rgba(11, 127, 120, 0.11);
--on-accent: #ffffff;
--ok: #1f8f4e;
--warn: #b45309;
--danger: #c93c3c;
--k-read: #2563eb;
--k-write: #b45309;
--k-search: #7c3aed;
--k-run: #15803d;
--k-plan: #0f766e;
--k-agent: #be185d;
--shadow-glow: 0 0 0 1px rgba(47, 98, 224, 0.35), 0 14px 40px -14px rgba(47, 98, 224, 0.35), 0 0 70px -34px rgba(11, 127, 120, 0.45);
--shadow-card: 0 1px 2px rgba(20, 30, 60, 0.05), 0 8px 24px -16px rgba(20, 30, 60, 0.25);
--tok-kw: #8a3fb0; --tok-str: #2f7d32; --tok-cmt: #76809a; --tok-num: #b06a00;
--tok-fn: #2456b8; --tok-type: #0d7a7a; --tok-const: #b4531f; --tok-var: #9a6700;
--tok-add: #1f7a3f; --tok-del: #b02a2a; --tok-hunk: #2456b8;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) {
--bg: #f5f7fc; --bg-elev: #ffffff; --bg-elev-2: #eef2fa; --bg-hover: #e5ebf7;
--glass: rgba(255, 255, 255, 0.78); --glass-strong: rgba(255, 255, 255, 0.92);
--border: #e0e6f1; --border-strong: #c9d3e6; --border-glass: rgba(40, 70, 160, 0.14);
--text: #0e1526; --text-dim: #475169; --text-faint: #76809a;
--accent: #0b7f78; --accent-2: #2f62e0; --accent-soft: rgba(11, 127, 120, 0.11); --on-accent: #ffffff;
--ok: #1f8f4e; --warn: #b45309; --danger: #c93c3c;
--k-read: #2563eb; --k-write: #b45309; --k-search: #7c3aed; --k-run: #15803d; --k-plan: #0f766e; --k-agent: #be185d;
--shadow-glow: 0 0 0 1px rgba(47, 98, 224, 0.35), 0 14px 40px -14px rgba(47, 98, 224, 0.35), 0 0 70px -34px rgba(11, 127, 120, 0.45);
--shadow-card: 0 1px 2px rgba(20, 30, 60, 0.05), 0 8px 24px -16px rgba(20, 30, 60, 0.25);
--tok-kw: #8a3fb0; --tok-str: #2f7d32; --tok-cmt: #76809a; --tok-num: #b06a00;
--tok-fn: #2456b8; --tok-type: #0d7a7a; --tok-const: #b4531f; --tok-var: #9a6700;
--tok-add: #1f7a3f; --tok-del: #b02a2a; --tok-hunk: #2456b8;
}
}
/* ---------------------------------------------------------------------
2. Base + ambient background
--------------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.6 var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Ambient light: two static radial washes behind everything. Static on
purpose — no animation, no GPU cost, nothing competing with the composer. */
body::before {
content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
background:
radial-gradient(900px 520px at 62% -8%, rgba(70, 120, 255, 0.16), transparent 70%),
radial-gradient(700px 420px at 18% 108%, rgba(45, 212, 191, 0.09), transparent 70%);
}
:root[data-theme="light"] body::before {
background:
radial-gradient(900px 520px at 62% -8%, rgba(70, 120, 255, 0.09), transparent 70%),
radial-gradient(700px 420px at 18% 108%, rgba(45, 212, 191, 0.07), transparent 70%);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme]) body::before {
background:
radial-gradient(900px 520px at 62% -8%, rgba(70, 120, 255, 0.09), transparent 70%),
radial-gradient(700px 420px at 18% 108%, rgba(45, 212, 191, 0.07), transparent 70%);
}
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button, input, textarea { font: inherit; color: inherit; }
::selection { background: var(--accent-soft); }
:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
kbd {
font: 600 10.5px/1 var(--font-mono); padding: 3px 5px; border-radius: 5px;
background: var(--bg-elev-2); border: 1px solid var(--border-strong); border-bottom-width: 2px; color: var(--text-dim);
}
/* Shared tool-glyph mask. Any element with .glyph gets a currentColor icon
chosen by a --glyph variable. */
.glyph, .tool-icon::before, .thinking-icon::before {
content: ""; display: block; width: 14px; height: 14px; background: currentColor;
-webkit-mask: var(--glyph, var(--i-cog)) center / contain no-repeat;
mask: var(--glyph, var(--i-cog)) center / contain no-repeat;
}
/* ---------------------------------------------------------------------
3. Sidebar
--------------------------------------------------------------------- */
.app-shell { display: flex; height: 100vh; height: 100dvh; overflow: hidden; }
.sidebar {
width: var(--sidebar-w); flex: 0 0 var(--sidebar-w);
display: flex; flex-direction: column; min-height: 0;
background: var(--glass); border-right: 1px solid var(--border-glass);
-webkit-backdrop-filter: blur(18px) saturate(1.3); backdrop-filter: blur(18px) saturate(1.3);
}
.sidebar-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 6px; }
.brand-lockup { display: flex; align-items: center; justify-content: center; gap: 10px; }
.brand-lockup h1 { margin: 0; font: 650 18px/1 var(--font-sans); letter-spacing: -0.02em; }
.logo-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.sidebar-header .brand-lockup img { width: 48px; height: 48px; border-radius: 12px; }
:root:not([data-theme="light"]) .sidebar-header .brand-lockup img { padding: 3px; }
@media (prefers-color-scheme: light) { :root:not([data-theme]) .sidebar-header .brand-lockup img { background: none; padding: 0; } }
.sidebar-close, .sidebar-open, .sidebar-backdrop { display: none; }
.new-chat-form { padding: 8px 14px 10px; }
.new-chat-btn {
width: 100%; display: flex; align-items: center; gap: 8px; justify-content: flex-start;
padding: 10px 12px; border-radius: var(--radius); cursor: pointer; font-weight: 550;
background: var(--bg-elev-2); border: 1px solid var(--border-strong);
transition: background 0.15s, border-color 0.15s;
}
.new-chat-btn:hover { background: var(--bg-hover); border-color: var(--accent-2); }
.plus-icon {
display: grid; place-items: center; width: 20px; height: 20px; border-radius: 7px;
background: var(--accent); color: var(--on-accent); font-weight: 700; line-height: 1;
}
.sidebar-section { display: flex; flex-direction: column; min-height: 0; padding: 4px 8px; }
.conversation-section { flex: 1 1 40%; }
.files-section { flex: 0 1 22%; border-top: 1px solid var(--border); padding-top: 8px; }
.workspace-section { flex: 0 1 28%; border-top: 1px solid var(--border); padding-top: 8px; }
.sidebar-section-title {
display: flex; justify-content: space-between; align-items: center; padding: 6px 8px 4px;
font: 600 12px/1.4 var(--font-sans); color: var(--text-faint); letter-spacing: 0.01em;
}
.conversation-list, .file-list { overflow-y: auto; min-height: 0; padding-bottom: 4px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
/* Conversations: active row gets a left rail, not a filled slab */
.conversation-item { position: relative; display: flex; align-items: center; border-radius: 9px; }
.conversation-item:hover, .conversation-item:focus-within { background: var(--bg-hover); }
.conversation-item.active { background: var(--accent-soft); }
.conversation-item.active::before {
content: ""; position: absolute; left: 0; top: 9px; bottom: 9px; width: 3px; border-radius: 3px; background: var(--accent);
}
.conversation-item.active .conversation-link { color: var(--text); font-weight: 550; }
.conversation-link {
flex: 1; min-width: 0; padding: 8px 12px; color: var(--text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.conversation-link:hover { text-decoration: none; color: var(--text); }
.conversation-actions { display: none; align-items: center; gap: 2px; padding-right: 4px; }
.conversation-item:hover .conversation-actions, .conversation-item:focus-within .conversation-actions { display: flex; }
.rename-form, .delete-form { display: flex; align-items: center; margin: 0; }
.rename-input { width: 0; padding: 0; border: 0; background: transparent; opacity: 0; pointer-events: none; }
.rename-form:focus-within .rename-input, .conversation-item:hover .rename-input {
width: 96px; padding: 3px 6px; border: 1px solid var(--border-strong); border-radius: 6px;
background: var(--bg); opacity: 1; pointer-events: auto; font-size: 12px;
}
.icon-btn {
width: 24px; height: 24px; display: grid; place-items: center; padding: 0;
background: transparent; border: 0; border-radius: 6px; color: var(--text-faint); cursor: pointer;
}
.icon-btn:hover { background: var(--bg-elev-2); color: var(--text); }
.icon-btn-danger:hover { color: var(--danger); }
.conversation-item-empty { padding: 8px 12px; color: var(--text-faint); font-size: 13px; }
.file-item { display: flex; gap: 6px; padding: 2px 10px; font: 12px/1.6 var(--font-mono); color: var(--text-dim); overflow: hidden; }
.file-item span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-icon { color: var(--accent); }
.file-item-empty { color: var(--text-faint); white-space: normal; }
/* Explorer: a glass card, like the "Ingredients you have" panel in the reference */
.explorer-section { padding: 6px 10px 10px; }
.explorer-section .file-tree {
padding: 6px; border: 1px solid var(--border-glass); border-radius: var(--radius);
background: var(--glass); box-shadow: var(--shadow-card);
}
/* Workspace files: upload form + download links (panel is currently commented out in the template) */
.files-title-right { display: flex; align-items: center; gap: 4px; }
.files-title-right .icon-btn { width: 18px; height: 18px; color: var(--text-faint); }
.upload-form { display: flex; gap: 6px; padding: 6px 8px 8px; align-items: center; }
.upload-input { flex: 1; min-width: 0; font-size: 11px; color: var(--text-dim); }
.upload-input::file-selector-button {
font: 11px var(--font-mono); padding: 4px 8px; margin-right: 6px; border-radius: 6px;
border: 1px solid var(--border-strong); background: var(--bg-elev-2); color: var(--text-dim); cursor: pointer;
}
.upload-input::file-selector-button:hover { border-color: var(--accent); color: var(--text); }
.upload-btn { flex: 0 0 auto; padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-strong); background: var(--bg-elev-2); color: var(--text-dim); cursor: pointer; font-size: 12px; }
.upload-btn:hover { border-color: var(--accent); color: var(--text); }
.upload-note { margin: 0 8px 6px; padding: 5px 8px; border-radius: 6px; font-size: 12px; }
.upload-note-ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); }
.upload-note-error { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.workspace-file-item { justify-content: space-between; }
.file-download-link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); font: inherit; }
.file-download-link:hover { color: var(--accent); text-decoration: none; }
.file-size { flex: 0 0 auto; color: var(--text-faint); font-size: 11px; }
/* Footer: one account row, one plan row, a compact theme switch */
.sidebar-footer { padding: 12px 14px 14px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
.plan-chip {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 10px;
background: var(--bg-elev-2); border: 1px solid var(--border); color: var(--text-dim);
font-size: 12.5px; text-decoration: none;
}
.plan-chip:hover { border-color: var(--accent-2); color: var(--text); text-decoration: none; }
.plan-dot { flex: 0 0 7px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.plan-chip span:nth-child(2) { flex: 1; font-weight: 550; color: var(--text); }
.plan-chip-manage { color: var(--text-faint); font-size: 12px; }
.user-chip { display: flex; align-items: center; gap: 10px; min-width: 0; }
.user-avatar {
flex: 0 0 30px; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center;
background: var(--accent-ring); color: var(--on-accent); font: 700 13px/1 var(--font-sans); text-transform: uppercase;
}
.status-dot { display: none; }
.user-email { flex: 1; min-width: 0; font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-chip form { margin: 0; }
.logout-btn { background: transparent; border: 1px solid var(--border-strong); border-radius: 8px; padding: 4px 10px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
.logout-btn:hover { color: var(--text); border-color: var(--accent-2); }
.model-tag { display: none; }
.theme-toggle { display: flex; gap: 2px; margin: 0; padding: 3px; background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 10px; }
.theme-btn { flex: 1; padding: 5px 6px; font-size: 12px; cursor: pointer; background: transparent; border: 0; border-radius: 7px; color: var(--text-faint); }
.theme-btn:hover { color: var(--text); }
.theme-btn.is-active { background: var(--bg-hover); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; }
/* Language switcher — same chrome as .theme-toggle (a select instead of
buttons, since there are too many locales for a button row), so it
reads as a sibling control rather than a one-off widget. Used in the
sidebar footer (index.html) and on every auth.html card. */
.lang-switcher { display: flex; gap: 4px; align-items: stretch; margin: 0; padding: 3px; background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 10px; }
.lang-select { flex: 1; min-width: 0; padding: 5px 6px; font-size: 12px; background: transparent; border: 0; border-radius: 7px; color: var(--text); }
.lang-select:hover, .lang-select:focus { background: var(--bg-hover); }
.lang-switcher-submit { padding: 5px 9px; font-size: 12px; cursor: pointer; background: transparent; border: 0; border-radius: 7px; color: var(--text-faint); }
.lang-switcher-submit:hover { color: var(--text); background: var(--bg-hover); }
.auth-card .lang-switcher { max-width: 220px; margin: 0 auto 14px; }
/* ---------------------------------------------------------------------
4. Top bar + command palette
--------------------------------------------------------------------- */
.main-col { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.chat-topbar { display: flex; align-items: center; gap: 12px; padding: 10px 20px; }
.chat-topbar h2 { flex: 1; margin: 0; font-size: 14.5px; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); }
/* The "Ask anything, find anything…" bar from the reference, repurposed as
a search-everything trigger. Opens the palette (app.js). */
.cmd-trigger {
flex: 0 1 380px; display: flex; align-items: center; gap: 10px; min-width: 0;
padding: 8px 10px 8px 14px; border-radius: 12px; cursor: pointer; text-align: left;
background: var(--glass); border: 1px solid var(--border-glass); color: var(--text-faint);
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
transition: border-color 0.15s, color 0.15s;
}
.cmd-trigger:hover { border-color: var(--accent-2); color: var(--text-dim); }
.cmd-trigger .glyph { --glyph: var(--i-search); width: 15px; height: 15px; flex: 0 0 auto; }
.cmd-trigger-label { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; }
.model-badge {
display: inline-flex; align-items: center; gap: 7px; font: 12px/1 var(--font-sans); color: var(--text-dim);
border: 1px solid var(--border-glass); background: var(--glass); border-radius: 999px; padding: 6px 11px; white-space: nowrap;
}
.model-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); }
.palette-overlay {
position: fixed; inset: 0; z-index: 900; display: none; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px;
background: color-mix(in srgb, var(--bg) 62%, transparent); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.palette-overlay.is-open { display: flex; }
.palette {
width: min(620px, 100%); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden;
background: var(--glass-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-glow);
-webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px);
}
.palette-input-row { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border-glass); }
.palette-input-row .glyph { --glyph: var(--i-search); width: 17px; height: 17px; color: var(--accent); flex: 0 0 auto; }
.palette-input { flex: 1; min-width: 0; background: transparent; border: 0; outline: 0; font-size: 16px; }
.palette-input::placeholder { color: var(--text-faint); }
.palette-list { overflow-y: auto; padding: 8px; scrollbar-width: thin; }
.palette-group { padding: 8px 10px 4px; font: 600 12px/1.4 var(--font-sans); color: var(--text-faint); }
.palette-item {
display: flex; align-items: center; gap: 12px; padding: 9px 10px; border-radius: 10px; cursor: pointer; color: var(--text-dim);
text-decoration: none;
}
.palette-item .glyph { width: 16px; height: 16px; color: var(--text-faint); flex: 0 0 auto; }
.palette-item-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.palette-item-hint { font-size: 12px; color: var(--text-faint); }
.palette-item.is-selected, .palette-item:hover { background: var(--bg-hover); text-decoration: none; }
.palette-item.is-selected .glyph { color: var(--accent); }
.palette-empty { padding: 22px; text-align: center; color: var(--text-faint); font-size: 14px; }
.palette-foot { display: flex; gap: 16px; padding: 9px 18px; border-top: 1px solid var(--border-glass); font-size: 12px; color: var(--text-faint); }
/* ---------------------------------------------------------------------
5. Chat window + empty state
--------------------------------------------------------------------- */
.chat-window {
flex: 1; min-height: 0; overflow-y: auto; padding: 20px max(16px, calc((100% - 820px) / 2)) 8px;
display: flex; flex-direction: column; gap: 24px; scroll-behavior: smooth;
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
}
.chat-window:has(.message-row) .empty-state { display: none; }
.empty-state { margin: auto; width: 100%; max-width: 760px; text-align: left; color: var(--text-dim); padding: 8px 0 24px; }
.hero { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8px; margin-bottom: 22px; }
.hero-mark {
width: 56px; height: 56px; display: grid; place-items: center; border-radius: 18px; color: var(--on-accent);
background: var(--accent-ring); box-shadow: 0 10px 40px -8px rgba(61, 120, 255, 0.6);
}
.hero-mark .glyph { --glyph: var(--i-spark); width: 26px; height: 26px; }
.empty-state h3 { margin: 6px 0 0; font-size: clamp(26px, 4.2vw, 36px); line-height: 1.15; color: var(--text); font-weight: 650; letter-spacing: -0.03em; }
.empty-state .hero p { margin: 0; max-width: 46ch; font-size: 15.5px; color: var(--text-dim); }
.block-title { margin: 0 0 10px 2px; font: 600 13px/1.4 var(--font-sans); color: var(--text-faint); }
/* Starters: the "Top recipe ideas" cards — glass tiles, one idea each */
.starters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 20px; }
.starter {
display: flex; flex-direction: column; gap: 8px; padding: 12px 15px 13px; text-align: left; cursor: pointer;
background: var(--glass); border: 1px solid var(--border-glass); border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
transition: border-color 0.15s, transform 0.15s, background 0.15s;
}
.starter:hover { border-color: color-mix(in srgb, var(--k, var(--accent-2)) 60%, transparent); background: var(--glass-strong); transform: translateY(-2px); }
.starter-icon {
width: 32px; height: 32px; display: grid; place-items: center; border-radius: 10px;
color: var(--k, var(--accent)); background: color-mix(in srgb, var(--k, var(--accent)) 14%, transparent);
}
.starter-icon .glyph { width: 17px; height: 17px; }
.starter-title { font-weight: 600; color: var(--text); font-size: 15px; line-height: 1.3; }
.starter-desc { font-size: 13.5px; color: var(--text-dim); line-height: 1.45; }
.starter-meta { margin-top: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-faint); }
/* "Ingredients you have" → what the agent can use in this workspace */
.tools-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.tool-chip {
display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 8px; border-radius: 999px;
background: var(--glass); border: 1px solid var(--border-glass); font-size: 13px; color: var(--text-dim);
}
.tool-chip .glyph { width: 15px; height: 15px; color: var(--k, var(--accent)); }
/* "More to explore" → quick prompt chips (fill, don't send) */
.explore { display: flex; flex-wrap: wrap; gap: 8px; }
.explore-chip {
padding: 7px 13px; border-radius: 999px; cursor: pointer; font-size: 13.5px; color: var(--text-dim);
background: transparent; border: 1px dashed var(--border-strong); transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.explore-chip:hover { border-style: solid; border-color: var(--accent-2); color: var(--text); background: var(--bg-hover); }
/* ---------------------------------------------------------------------
6. Messages, thinking, tool steps
--------------------------------------------------------------------- */
.message-row { display: flex; flex-direction: column; gap: 6px; max-width: 100%; animation: msg-in 0.28s cubic-bezier(0.2, 0.7, 0.3, 1); }
@keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.role-user, .role-assistant { flex-direction: row; align-items: flex-start; gap: 12px; }
.role-user { flex-direction: row-reverse; justify-content: flex-start; }
.message-content { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.role-user .message-content { flex: 0 1 auto; align-items: flex-end; max-width: 82%; margin-left: auto; }
.avatar { flex: 0 0 30px; width: 30px; height: 30px; margin-top: 0; border-radius: 10px; display: flex; align-items: center; justify-content: center; user-select: none; }
.avatar-assistant { background: var(--accent-ring); color: var(--on-accent); box-shadow: 0 6px 20px -8px rgba(61, 120, 255, 0.7); }
.message-meta { display: flex; align-items: center; gap: 10px; height: 30px; font: 650 13px/1 var(--font-sans); color: var(--text); }
.meta-name { letter-spacing: -0.01em; }
.bubble { max-width: 100%; overflow-wrap: anywhere; }
.role-user .bubble {
padding: 10px 16px; background: var(--glass-strong); border: 1px solid var(--border-glass);
border-radius: 18px 18px 5px 18px; white-space: normal; box-shadow: var(--shadow-card);
}
.role-assistant .bubble { padding: 2px 0; font-size: 15px; line-height: 1.7; }
.bubble-error { padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); }
.role-system { align-items: center; }
/* Rendered Markdown (calm: only links and inline code take colour) */
.bubble > :first-child { margin-top: 0; }
.bubble > :last-child { margin-bottom: 0; }
.bubble p { margin: 0 0 0.85em; }
.bubble ul, .bubble ol { margin: 0 0 0.85em; padding-left: 1.4em; }
.bubble li { margin: 0.25em 0; }
.bubble h1, .bubble h2, .bubble h3, .bubble h4 { margin: 1.25em 0 0.5em; line-height: 1.3; font-weight: 650; letter-spacing: -0.015em; }
.bubble h1 { font-size: 1.32em; } .bubble h2 { font-size: 1.16em; } .bubble h3 { font-size: 1.05em; }
.bubble blockquote { margin: 0 0 0.85em; padding: 2px 0 2px 14px; border-left: 3px solid var(--accent-2); color: var(--text-dim); }
.bubble hr { border: 0; border-top: 1px solid var(--border); margin: 1.2em 0; }
.bubble table { border-collapse: separate; border-spacing: 0; margin: 0 0 0.9em; display: block; overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; }
.bubble th, .bubble td { border-bottom: 1px solid var(--border); padding: 7px 13px; }
.bubble tr:last-child td { border-bottom: 0; }
.bubble th { background: var(--bg-elev); font-weight: 600; text-align: left; }
.bubble code { font: 0.88em var(--font-mono); background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; }
.bubble a { text-underline-offset: 2px; }
.bubble pre { margin: 0 0 0.85em; }
.bubble pre code { display: block; padding: 14px 16px; overflow-x: auto; background: var(--bg-elev); border: 0; border-radius: 0 0 12px 12px; line-height: 1.55; }
.role-assistant .bubble a { color: var(--accent-2); text-decoration: underline; }
.role-assistant .bubble :not(pre) > code { color: var(--tok-fn); background: color-mix(in srgb, var(--tok-fn) 11%, transparent); border-color: color-mix(in srgb, var(--tok-fn) 26%, transparent); }
.role-assistant .bubble li::marker { color: var(--accent); }
.role-assistant .bubble mark { background: var(--accent-soft); color: inherit; border-radius: 3px; padding: 0 3px; }
/* Code blocks: header with language + copy button (see render/markdown.go) */
.code-block { margin: 0 0 1em; border: 1px solid var(--border-strong); border-radius: 12px; overflow: hidden; background: var(--bg-elev); box-shadow: var(--shadow-card); }
.code-block pre { margin: 0; }
.code-block pre code { border: 0; border-radius: 0; }
.code-block-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 7px 8px 7px 14px; background: var(--bg-elev-2); border-bottom: 1px solid var(--border); font: 12px/1.4 var(--font-mono); color: var(--text-faint); }
.code-block-lang { display: flex; align-items: center; gap: 8px; text-transform: lowercase; }
.code-block-lang::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; }
.copy-btn { display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 1px solid transparent; border-radius: 7px; padding: 3px 10px; font-size: 12px; color: var(--text-faint); cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s; }
.copy-btn::before { content: "⧉"; font-size: 12px; }
.copy-btn:hover { color: var(--text); background: var(--bg-hover); border-color: var(--border-strong); }
/* Streaming affordances */
.typing-dots { display: inline-flex; gap: 5px; padding: 9px 0; }
.typing-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-2); opacity: 0.4; animation: blink 1.2s infinite ease-in-out; }
.typing-dots span:nth-child(2) { animation-delay: 0.15s; }
.typing-dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes blink { 0%, 80%, 100% { opacity: 0.28; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-2px); } }
.stop-form { margin: 0 0 0 auto; }
.stop-btn {
display: inline-flex; align-items: center; gap: 7px; background: var(--glass); border: 1px solid var(--border-strong);
border-radius: 999px; padding: 4px 12px 4px 10px; font: 600 12px/1.6 var(--font-sans); color: var(--text-dim); cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.stop-icon { width: 8px; height: 8px; border-radius: 2px; background: currentColor; display: inline-block; }
.stop-btn:hover { color: var(--danger); border-color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); }
.message-row:has(.stream-done) .stop-form { display: none; }
/* Thinking: a quiet pill while the model reasons, shimmering label only
until the reply starts. Auto-collapsed by app.js into "Thought for Ns". */
.thinking-card {
display: none; background: var(--glass); border: 1px solid var(--border-glass); border-radius: 14px;
padding: 8px 14px; margin: 0 0 2px; color: var(--text-faint); font-size: 13px;
}
.thinking-card:has(.thinking-body:not(:empty)) { display: block; }
.thinking-card summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; font: 600 12.5px/1.6 var(--font-sans); color: var(--text-dim); }
.thinking-card summary::-webkit-details-marker { display: none; }
.thinking-card summary::after { content: "▾"; margin-left: auto; color: var(--text-faint); font-size: 10px; transition: transform 0.15s; }
.thinking-card:not([open]) summary::after { transform: rotate(-90deg); }
.thinking-icon { display: inline-flex; color: var(--accent-2); width: 14px; }
.thinking-icon::before { --glyph: var(--i-spark); }
.message-row:not(:has(.stream-done)) .thinking-card:has(.thinking-body:not(:empty)) .thinking-label {
background: linear-gradient(100deg, var(--text-faint) 30%, var(--text) 50%, var(--text-faint) 70%) 0 0 / 220% 100%;
-webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent;
animation: shimmer 2.2s linear infinite;
}
@keyframes shimmer { to { background-position: -220% 0; } }
.message-row:not(:has(.stream-done)) .thinking-card:has(.thinking-body:not(:empty)) .thinking-icon { animation: thinking-pulse 1.4s ease-in-out infinite; }
@keyframes thinking-pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
.thinking-elapsed { font-weight: 400; color: var(--text-faint); }
.message-row:has(.stream-done) .thinking-elapsed:empty::before { content: "finished"; }
.thinking-body { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); max-height: 260px; overflow-y: auto; white-space: normal; font-style: italic; line-height: 1.55; }
/* Tool steps: the "source cards" of an agent run. A coloured icon tile says
what KIND of step it was at a glance; the summary says what it touched. */
.role-tool { align-items: stretch; }
.tool-card {
--k: var(--accent);
border: 1px solid var(--border-glass); border-radius: 14px; background: var(--glass); overflow: hidden;
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
transition: border-color 0.15s, background 0.15s;
}
.tool-card:hover { border-color: color-mix(in srgb, var(--k) 45%, var(--border-glass)); }
.tool-card[open] { background: var(--glass-strong); }
.tool-card summary { display: flex; align-items: center; gap: 10px; padding: 8px 12px 8px 9px; cursor: pointer; list-style: none; font: 12.5px/1.5 var(--font-mono); color: var(--text-dim); }
.tool-card summary::-webkit-details-marker { display: none; }
.tool-card summary:hover { background: var(--bg-hover); }
.tool-icon {
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 8px;
color: var(--k); background: color-mix(in srgb, var(--k) 15%, transparent);
}
.tool-icon::before { width: 14px; height: 14px; }
.tool-icon-read_file { --glyph: var(--i-file); }
.tool-icon-write_file, .tool-icon-edit_file, .tool-icon-multi_edit_file { --glyph: var(--i-edit); }
.tool-icon-list_directory, .tool-icon-glob_search { --glyph: var(--i-folder); }
.tool-icon-grep, .tool-icon-search_codebase { --glyph: var(--i-search); }
.tool-icon-run_bash_command { --glyph: var(--i-term); }
.tool-icon-write_todos { --glyph: var(--i-todo); }
.tool-icon-dispatch_subagent { --glyph: var(--i-spark); }
/* Kind colour: set on the card via :has so the template stays unchanged */
.tool-card:has(.tool-icon-read_file), .tool-card:has(.tool-icon-list_directory), .tool-card:has(.tool-icon-glob_search) { --k: var(--k-read); }
.tool-card:has(.tool-icon-write_file), .tool-card:has(.tool-icon-edit_file), .tool-card:has(.tool-icon-multi_edit_file) { --k: var(--k-write); }
.tool-card:has(.tool-icon-grep), .tool-card:has(.tool-icon-search_codebase) { --k: var(--k-search); }
.tool-card:has(.tool-icon-run_bash_command) { --k: var(--k-run); }
.tool-card:has(.tool-icon-write_todos) { --k: var(--k-plan); }
.tool-card:has(.tool-icon-dispatch_subagent) { --k: var(--k-agent); }
.tool-card.is-error { --k: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
.tool-card.is-subagent { border-left: 3px solid var(--k-agent); }
/* One round's reasoning trace, same chrome as a tool step */
.tool-card.is-thinking-round { --k: var(--accent-2); }
.tool-card.is-thinking-round .tool-body .tool-output { font-style: italic; white-space: pre-wrap; }
.tool-card.is-thinking-round summary .thinking-icon { width: 24px; height: 24px; justify-content: center; align-items: center; border-radius: 8px; color: var(--k); background: color-mix(in srgb, var(--k) 15%, transparent); }
.tool-name { color: var(--text); font-weight: 600; }
.tool-summary { flex: 1; min-width: 0; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tool-chevron { flex: 0 0 auto; margin-left: auto; color: var(--text-faint); transition: transform 0.15s; }
.tool-chevron::before { content: "▸"; }
.tool-card[open] .tool-chevron { transform: rotate(90deg); }
.tool-body { border-top: 1px solid var(--border); max-height: 360px; overflow: auto; }
.tool-output { margin: 0; padding: 12px 14px; font: 12px/1.55 var(--font-mono); color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; }
/* ---------------------------------------------------------------------
7. Composer — the one luminous thing on the page
--------------------------------------------------------------------- */
.composer { padding: 8px max(16px, calc((100% - 820px) / 2)) 6px; }
.composer-form {
position: relative; display: flex; align-items: flex-end; gap: 8px; padding: 10px 10px 10px 10px;
background: var(--glass-strong); border: 1px solid var(--border-strong); border-radius: 22px;
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
transition: box-shadow 0.25s, border-color 0.25s;
}
/* Gradient ring, revealed on focus. Drawn as a masked pseudo-element so the
border keeps its radius; browsers without mask-composite just keep the
solid border + glow shadow. */
.composer-form::before {
content: ""; position: absolute; inset: -1px; border-radius: inherit; padding: 1px; pointer-events: none;
background: var(--accent-ring); opacity: 0; transition: opacity 0.25s;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor; mask-composite: exclude;
}
.composer-form:focus-within { border-color: transparent; box-shadow: var(--shadow-glow); }
.composer-form:focus-within::before { opacity: 1; }
.composer-form textarea {
flex: 1; min-height: 32px; max-height: 220px; resize: none; padding: 6px 0;
background: transparent; border: 0; outline: 0; line-height: 1.5; font-size: 15.5px;
overflow-y: auto; transition: height 0.08s ease-out;
}
.composer-form textarea::placeholder { color: var(--text-faint); }
.attach-btn {
flex: 0 0 34px; width: 34px; height: 34px; display: grid; place-items: center;
border: 1px solid var(--border-strong); border-radius: 50%; background: transparent; color: var(--text-dim); cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
}
.attach-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--accent-2); }
.attach-btn:active { transform: scale(0.94); }
/* Send: a pill with a label on wide screens (the reference's "Ask") */
.send-btn {
flex: 0 0 auto; height: 36px; display: inline-flex; align-items: center; gap: 7px; padding: 0 16px 0 13px;
border: 0; border-radius: 999px; background: var(--accent-ring); color: var(--on-accent); font-weight: 650; font-size: 14px; cursor: pointer;
box-shadow: 0 6px 20px -8px rgba(61, 120, 255, 0.8); transition: filter 0.15s, opacity 0.15s, transform 0.15s;
}
.send-btn svg { flex: 0 0 auto; }
.send-btn:hover { filter: brightness(1.08); }
.send-btn:active { transform: scale(0.97); }
.composer-form[data-empty="true"] .send-btn { opacity: 0.45; box-shadow: none; }
.composer-hint { margin-top: 8px; display: flex; justify-content: center; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--text-faint); }
.composer-hint span { display: inline-flex; align-items: center; gap: 6px; }
/* Staged-file tray above the box; chips share one shape with sent messages */
.composer-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 max(16px, calc((100% - 820px) / 2)) 8px; }
.composer-attachments:empty { display: none; margin: 0; }
.attach-chip {
position: relative; display: flex; align-items: center; gap: 8px; max-width: 230px; padding: 5px 10px 5px 5px;
background: var(--glass-strong); border: 1px solid var(--border-strong); border-radius: 12px;
font-size: 12.5px; color: var(--text-dim); transition: border-color 0.15s;
}
.attach-chip.is-error { border-color: var(--danger); color: var(--danger); }
.attach-chip.is-archive { max-width: 270px; }
.attach-chip.is-archive .attach-chip-name { max-width: 210px; }
.attach-chip.is-archive .attach-chip-preview, .msg-attachment.is-archive .msg-attachment-icon { color: var(--accent); }
.attach-chip-preview { position: relative; flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; overflow: hidden; display: grid; place-items: center; background: var(--bg-elev); border: 1px solid var(--border); color: var(--text-faint); }
.attach-chip-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attach-chip-name { min-width: 0; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.attach-chip-remove { flex: 0 0 auto; width: 18px; height: 18px; display: grid; place-items: center; margin-left: 2px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-faint); font-size: 14px; line-height: 1; cursor: pointer; }
.attach-chip-remove:hover { background: var(--bg-hover); color: var(--text); }
.attach-chip-spinner { display: none; }
.attach-chip.is-uploading .attach-chip-spinner { display: block; position: absolute; inset: 0; border-radius: inherit; background: color-mix(in srgb, var(--bg-elev-2) 55%, transparent); }
.attach-chip.is-uploading .attach-chip-spinner::after { content: ""; position: absolute; top: 50%; left: 50%; width: 14px; height: 14px; margin: -7px 0 0 -7px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; animation: attach-spin 0.7s linear infinite; }
@keyframes attach-spin { to { transform: rotate(360deg); } }
.msg-attachments { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.msg-attachment { display: flex; align-items: center; gap: 8px; max-width: 230px; padding: 5px 11px 5px 5px; background: var(--bg-elev-2); border: 1px solid var(--border-strong); border-radius: 12px; font-size: 12.5px; color: var(--text-dim); }
.msg-attachment:hover { border-color: var(--accent-2); text-decoration: none; }
.msg-attachment-thumb { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; object-fit: cover; }
.msg-attachment-icon { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; background: var(--bg-elev); border: 1px solid var(--border); color: var(--text-faint); }
.msg-attachment-name { min-width: 0; max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.msg-attachment.is-archive { max-width: 270px; }
.msg-attachment.is-archive .msg-attachment-name { max-width: 210px; }
/* ---------------------------------------------------------------------
8. Mobile: sidebar becomes an off-canvas drawer (checkbox + <label>)
--------------------------------------------------------------------- */
@media (max-width: 800px) {
.sidebar { position: fixed; z-index: 30; inset: 0 auto 0 0; transform: translateX(-100%); transition: transform 0.2s ease; box-shadow: 8px 0 30px rgba(0, 0, 0, 0.5); background: var(--glass-strong); }
.sidebar-toggle-input:checked + .app-shell .sidebar { transform: none; }
.sidebar-backdrop { position: fixed; z-index: 20; inset: 0; background: rgba(0, 0, 0, 0.55); opacity: 0; pointer-events: none; transition: opacity 0.2s; display: block; }
.sidebar-toggle-input:checked + .app-shell .sidebar-backdrop { opacity: 1; pointer-events: auto; }
.sidebar-close, .sidebar-open { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; cursor: pointer; font-size: 20px; color: var(--text-dim); }
.sidebar-close:hover, .sidebar-open:hover { background: var(--bg-hover); }
.conversation-actions { display: flex; }
.model-badge { display: none; }
.chat-topbar { padding: 8px 12px; }
.cmd-trigger { flex: 0 0 auto; padding: 8px 10px; }
.cmd-trigger-label, .cmd-trigger kbd { display: none; }
.chat-window, .composer { padding-left: 14px; padding-right: 14px; }
.starters { grid-template-columns: 1fr; }
.send-btn { padding: 0 12px; }
.send-btn-label, .composer-hint .hint-kbd { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
/* ---------------------------------------------------------------------
9. Auth pages
--------------------------------------------------------------------- */
.auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.auth-card { width: 100%; max-width: 400px; padding: 30px; background: var(--glass-strong); border: 1px solid var(--border-glass); border-radius: var(--radius-lg); box-shadow: var(--shadow-glow); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); }
.auth-card .brand-lockup { margin-bottom: 18px; }
/* The enkly logo artwork (dark-grey linework + teal accents) is drawn
for a light surface; placed directly on this theme's near-black
--bg-elev it would lose most of its contrast. A small light chip
behind it (not a recolor of the artwork itself) fixes that without
touching the logo file. The wordmark is already drawn into the
artwork, so the adjacent <h1> here is set sr-only rather than
shown a second time next to it — still present for accessibility
and for anything that reads the page's heading structure. */
.auth-card .brand-lockup .logo-mark {
height: 96px;
width: auto;
padding: 6px 10px;
/*background: #fbfaf7;
border-radius: 10px;*/
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.auth-card h2 { margin: 0 0 4px; font-size: 20px; }
.auth-subtitle { margin: 0 0 18px; color: var(--text-dim); font-size: 14px; }
.auth-message { margin: 0 0 14px; padding: 9px 12px; border-radius: 8px; font-size: 14px; }
.auth-message.error { color: var(--danger); background: color-mix(in srgb, var(--danger) 9%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
.auth-message.info { color: var(--text); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
.auth-form { display: flex; flex-direction: column; gap: 14px; }
.auth-field { display: flex; flex-direction: column; gap: 5px; }
.auth-field label { font-size: 13px; color: var(--text-dim); }
.auth-field input { padding: 9px 11px; background: var(--bg); border: 1px solid var(--border-strong); border-radius: 8px; outline: 0; }
.auth-field input:focus { border-color: var(--accent); }
/* Eye icon for password fields — web/static/js/password-toggle.js
inserts the <button> into every .password-field (see auth.html). */
.password-field { position: relative; }
.password-field input { width: 100%; box-sizing: border-box; padding-right: 38px; }
.password-toggle {
position: absolute; top: 50%; right: 4px; transform: translateY(-50%);
width: 28px; height: 28px; display: grid; place-items: center;
border: 0; border-radius: 6px; background: transparent; color: var(--text-faint); cursor: pointer;
}
.password-toggle:hover { background: var(--bg-elev-2); color: var(--text); }
.password-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.auth-btn { padding: 11px 14px; border: 0; border-radius: 12px; background: var(--accent-ring); color: var(--on-accent); font-weight: 650; cursor: pointer; }
.auth-btn:hover { filter: brightness(1.08); }
.auth-btn-secondary { background: transparent; border: 1px solid var(--border-strong); color: var(--text); }
.auth-btn-secondary:hover { filter: none; border-color: var(--accent); }
.auth-inline-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
#verify-code { letter-spacing: 4px; font-variant-numeric: tabular-nums; text-align: center; }
.auth-footer { margin-top: 14px; text-align: center; font-size: 13px; color: var(--text-dim); }
/* ---------------------------------------------------------------------
Subscription / billing page
--------------------------------------------------------------------- */
.billing-shell { min-height: 100vh; padding: 24px; max-width: 980px; margin: 0 auto; }
.billing-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
.billing-back { color: var(--text-dim); font-size: 14px; }
.billing-back:hover { color: var(--accent); text-decoration: none; }
.billing-card { padding: 28px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px; }
.billing-card h2 { margin: 0 0 4px; font-size: 22px; }
.plan-status { display: inline-block; margin-left: 4px; color: var(--text-dim); }
.plan-status-warn { color: var(--danger); }
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin: 22px 0; }
.plan-card {
position: relative; display: flex; flex-direction: column; gap: 10px; padding: 18px 16px;
background: var(--bg-elev-2); border: 1px solid var(--border); border-radius: 12px;
}
.plan-card-current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
.plan-badge {
position: absolute; top: -10px; right: 14px; padding: 2px 8px; border-radius: 999px;
background: var(--accent); color: var(--on-accent); font-size: 11px; font-weight: 700;
}
.plan-card-name { font-size: 15px; font-weight: 600; color: var(--text); }
.plan-card-price { font-size: 26px; font-weight: 700; color: var(--text); }
.plan-card-period { font-size: 13px; font-weight: 400; color: var(--text-faint); }
.plan-card-features { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.plan-card-features li { font-size: 12.5px; color: var(--text-dim); padding-left: 14px; position: relative; }
.plan-card-features li::before { content: "✓"; position: absolute; left: 0; color: var(--ok); }
.plan-form { margin: 0; }
.plan-btn { width: 100%; }
.plan-btn:disabled { cursor: default; opacity: 0.6; filter: none; }
.plan-unavailable-reason { margin: 0; font-size: 11px; color: var(--text-faint); line-height: 1.4; }
.billing-portal-form { margin: 0 0 16px; }
.billing-footnote { margin: 0; font-size: 12px; color: var(--text-faint); }
@media (max-width: 640px) {
.plan-grid { grid-template-columns: 1fr 1fr; }
}
/* ---------------------------------------------------------------------
Explorer tree (lazy-expanding; see web/templates/tree.html)
--------------------------------------------------------------------- */
.file-tree { max-height: 260px; overflow: auto; font: 13px/1.5 var(--font-mono); }
.tree-list { list-style: none; margin: 0; padding: 0; }
.tree-list .tree-list { padding-left: 14px; border-left: 1px solid var(--border); margin-left: 6px; }
.tree-dir, .tree-file { display: flex; flex-wrap: wrap; align-items: center; }
.tree-dir > .tree-list { flex: 1 0 100%; }
.tree-node {
display: inline-flex; align-items: center; gap: 6px; padding: 1px 4px;
background: none; border: 0; border-radius: 4px; color: var(--text-dim);
cursor: pointer; text-align: left; font: inherit; text-decoration: none;
}
.tree-node:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
.tree-icon { width: 1em; display: inline-block; text-align: center; color: var(--text-faint); }
.tree-file .file-size { margin-left: auto; color: var(--text-faint); font-size: 11px; }
.tree-empty { color: var(--text-faint); padding: 2px 4px; font-style: italic; }
/* Status bar: a quiet strip under the composer. Context usage is a meter. */
.status-bar { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 18px; align-items: center; padding: 2px 16px 10px; color: var(--text-faint); font: 12px/1.6 var(--font-sans); min-height: 26px; }
.status-item { display: inline-flex; align-items: center; gap: 7px; }
.status-bar .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.status-bar .dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); }
.status-bar .dot.bad { background: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); }
.ctx-meter { width: 64px; height: 4px; border-radius: 4px; background: var(--bg-elev-2); overflow: hidden; }
.ctx-meter > span { display: block; height: 100%; border-radius: inherit; background: var(--accent-ring); }
.ctx-meter.is-high > span { background: var(--warn); }
.ctx-meter.is-full > span { background: var(--danger); }
/* ---------------------------------------------------------------------
Syntax highlighting classes (palette lives in the tokens above)
--------------------------------------------------------------------- */
.tok-kw { color: var(--tok-kw); font-weight: 600; }
.tok-str { color: var(--tok-str); }
.tok-cmt { color: var(--tok-cmt); font-style: italic; }
.tok-num { color: var(--tok-num); }
.tok-fn { color: var(--tok-fn); }
.tok-type { color: var(--tok-type); }
.tok-const { color: var(--tok-const); }
.tok-var { color: var(--tok-var); }
.tok-key { color: var(--tok-fn); }
.tok-add { color: var(--tok-add); }
.tok-del { color: var(--tok-del); }
.tok-hunk { color: var(--tok-hunk); }
/* =====================================================================
Download cards at the end of a reply (web/templates/deliverables.html)
Icon tile + name/type + Download button.
===================================================================== */
.deliverables { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 4px; }
.deliverable-card {
display: flex; align-items: center; gap: 16px;
padding: 12px 14px; background: var(--bg-elev);
border: 1px solid var(--border-strong); border-radius: 14px;
}
.deliverable-tile {
flex: 0 0 auto; width: 52px; height: 56px; display: grid; place-items: center;
background: var(--bg-elev-2); border: 1px solid var(--border-strong);
border-radius: 10px; color: var(--text-dim);
}
.deliverable-meta { flex: 1; min-width: 0; }
.deliverable-name {
font-size: 16px; color: var(--text); line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.deliverable-type { font-size: 13px; color: var(--text-faint); letter-spacing: 0.02em; }
.deliverable-btn {
flex: 0 0 auto; padding: 8px 18px; cursor: pointer;
background: var(--bg-elev-2); color: var(--text);
border: 1px solid var(--border-strong); border-radius: 10px;
font-size: 14px; text-decoration: none;
}
.deliverable-btn:hover { background: var(--bg-hover); text-decoration: none; }
.deliverable-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent); }
@media (max-width: 480px) {
.deliverable-card { gap: 10px; padding: 10px; }
.deliverable-tile { width: 42px; height: 46px; }
}
/* Drag & drop upload (web/static/js/app.js) */
.drop-overlay {
position: fixed; inset: 0; z-index: 1000; display: none; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--bg) 72%, transparent); backdrop-filter: blur(2px); pointer-events: none;
}
.drop-overlay.is-visible { display: flex; }
.drop-overlay-box {
display: flex; flex-direction: column; gap: 6px; align-items: center; text-align: center;
padding: 44px 64px; border: 2px dashed var(--accent); border-radius: 18px;
background: var(--accent-soft); color: var(--text);
}
.drop-overlay-box strong { font-size: 18px; color: var(--accent); }
.drop-overlay-box span { font-size: 13px; color: var(--text-dim); }
.drop-toast {
position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%); z-index: 1001;
max-width: min(90vw, 480px); padding: 8px 14px; border-radius: 10px; font-size: 13px;
background: var(--bg-elev-2); border: 1px solid var(--border-strong); color: var(--text);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); transition: opacity 0.4s;
}
.drop-toast.is-error { border-color: var(--danger); color: var(--danger); }
.drop-toast.is-leaving { opacity: 0; }
/* Drop target: the composer textarea itself */
body.drag-composer .drop-overlay { display: none; }
.composer-form { position: relative; transition: box-shadow 0.15s, border-color 0.15s; }
body.drag-composer .composer-form {
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
body.drag-composer #composer-input::placeholder { color: transparent; }
body.drag-composer .composer-form::after {
content: "Drop files here to attach"; position: absolute; left: 16px; top: 50%; transform: translateY(-50%);
color: var(--accent); font-size: 14px; pointer-events: none;
}
/* Subscription page: usage meter + plan polish */
.usage-card { margin: 18px 0 4px; padding: 12px 14px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--bg-elev); }
.usage-head { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-dim); }
.usage-head strong { color: var(--text); }
.usage-bar { height: 8px; margin-top: 8px; border-radius: 99px; background: var(--bg-elev-2); overflow: hidden; }
.usage-fill { height: 100%; border-radius: 99px; background: var(--ok); transition: width 0.4s; }
.usage-near .usage-fill { background: var(--tok-num, #e5c07b); }
.usage-at .usage-fill { background: var(--danger); }
.usage-note { margin: 8px 0 0; font-size: 12.5px; color: var(--text-dim); }
.usage-at .usage-note { color: var(--danger); }
.plan-card { position: relative; }
.plan-card-popular:not(.plan-card-current) { border-color: var(--accent); }
.plan-badge-popular { background: var(--accent-2); color: #fff; }
.plan-perk { color: var(--text) !important; font-weight: 600; }
/* Logo artwork is dark linework: give it a light chip on dark auth surfaces */
:root:not([data-theme="light"]) .auth-card .brand-lockup .logo-mark { /*background: #f4f6fb; border-radius: 14px;*/ padding: 6px 12px; }
@media (prefers-color-scheme: light) { :root:not([data-theme]) .auth-card .brand-lockup .logo-mark { background: none; padding: 6px 10px; } }
@media (max-height: 820px) { .hero p { display: none; } .hero-mark { width: 46px; height: 46px; } }
/* Consecutive agent steps read as one stack, not separate messages */
.role-tool + .role-tool { margin-top: -15px; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,673 @@
/*
* enkly — app.js
*
* A small, dependency-free enhancement layer. It is served same-origin
* (the server's Content-Security-Policy is script-src 'self'), and none of
* it uses eval or inline handlers. Everything here is optional except the
* "clear the composer after sending" behaviour, which replaces what used to
* be an hx-on attribute (htmx evaluates hx-on with eval(), which the CSP
* blocks).
*/
(function () {
"use strict";
var chat = document.getElementById("chat-window");
var input = document.getElementById("composer-input");
/* ------------------------------------------------------------------
* Auto-scroll: follow the stream unless the user scrolled up to read.
* ------------------------------------------------------------------ */
var stickToBottom = true;
function nearBottom() {
return chat.scrollHeight - chat.scrollTop - chat.clientHeight < 120;
}
function scrollToBottom(smooth) {
chat.scrollTo({ top: chat.scrollHeight, behavior: smooth ? "smooth" : "auto" });
}
if (chat) {
scrollToBottom(false);
chat.addEventListener("scroll", function () { stickToBottom = nearBottom(); }, { passive: true });
new MutationObserver(function () {
if (stickToBottom) scrollToBottom(false);
}).observe(chat, { childList: true, subtree: true, characterData: true });
}
/* ------------------------------------------------------------------
* Composer: auto-resize, Enter to send, clear after a successful send.
* ------------------------------------------------------------------ */
function resizeInput() {
if (!input) return;
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 220) + "px";
}
// A send is valid once there's either typed text or at least one
// attachment that isn't still mid-upload (see "Composer attachments"
// below) — an attachment-only message is fine, same as Claude Code.
function canSend() {
if (input && input.value.trim() !== "") return true;
return attachmentsReadyCount() > 0;
}
if (input) {
input.addEventListener("input", resizeInput);
input.addEventListener("keydown", function (e) {
// Enter sends; Shift+Enter inserts a newline; ignore Enter while an
// IME composition (CJK input, etc.) is confirming a candidate.
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
e.preventDefault();
if (canSend() && input.form) input.form.requestSubmit();
}
});
input.focus();
// Covers the case where the browser restores a typed-but-unsent value
// on reload (bfcache / "preserve form on back"), so the box already
// matches its content's height instead of starting at one line.
resizeInput();
}
// Block the actual request (rather than just the Enter shortcut above)
// if an attachment is still uploading when Send is clicked directly, or
// let it through with nothing at all so the server's own "Type a
// message first." validation handles that case exactly as before.
document.body.addEventListener("htmx:beforeRequest", function (e) {
var elt = e.detail && e.detail.elt;
if (!elt || !elt.classList || !elt.classList.contains("composer-form")) return;
if (attachmentsPendingCount() > 0) {
e.preventDefault();
toast("Still uploading \u2014 one moment.", true);
}
});
// htmx fires this after the /chat request completes. Clear the box and
// re-enable following the stream (sending a message means "I'm here").
document.body.addEventListener("htmx:afterRequest", function (e) {
var elt = e.detail && e.detail.elt;
if (elt && elt.classList && elt.classList.contains("composer-form") && e.detail.successful) {
elt.reset();
resizeInput();
clearAttachments();
stickToBottom = true;
if (chat) scrollToBottom(true);
if (input) input.focus();
}
});
/* ------------------------------------------------------------------
* Copy button on code blocks (event delegation: works for blocks that
* arrive later over SSE).
* ------------------------------------------------------------------ */
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy") ? resolve() : reject(new Error("copy failed"));
} catch (err) {
reject(err);
} finally {
document.body.removeChild(ta);
}
});
}
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".copy-btn");
if (!btn) return;
var block = btn.closest(".code-block");
var code = block && block.querySelector("pre code");
if (!code) return;
copyText(code.innerText).then(function () {
btn.textContent = "Copied";
}, function () {
btn.textContent = "Press Ctrl+C";
}).then(function () {
setTimeout(function () { btn.textContent = "Copy"; }, 1500);
});
});
/* ------------------------------------------------------------------
* Snippet download cards: build the file in the browser from the code
* block the card refers to (the Nth .code-block in the same reply), so
* no server round-trip or temp file is needed. File cards are plain
* links and need no JS.
* ------------------------------------------------------------------ */
// SNIPPET-DOWNLOAD:BEGIN (scripts/test_snippet_download.mjs extracts this block)
function downloadSnippet(btn) {
var bubble = btn.closest(".bubble");
var blocks = bubble ? bubble.querySelectorAll(".code-block pre code") : [];
var idx = parseInt(btn.getAttribute("data-snippet-index"), 10);
var code = isNaN(idx) ? null : blocks[idx];
if (!code) return false;
// textContent (not innerText) so the file is exactly the source text,
// regardless of the highlight <span>s or CSS.
var blob = new Blob([code.textContent], { type: "text/plain;charset=utf-8" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = btn.getAttribute("data-filename") || "snippet.txt";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
return true;
}
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".snippet-download");
if (!btn) return;
var ok = downloadSnippet(btn);
var label = btn.getAttribute("data-label") || btn.textContent;
btn.setAttribute("data-label", label);
btn.textContent = ok ? "Downloaded" : "Unavailable";
setTimeout(function () { btn.textContent = label; }, 1500);
});
// SNIPPET-DOWNLOAD:END
/* ------------------------------------------------------------------
* Stop button: cancel via fetch instead of navigating away from the
* live stream. (Without JS the form still works — it POSTs and the
* server redirects back to the conversation.)
* ------------------------------------------------------------------ */
document.addEventListener("submit", function (e) {
var form = e.target;
if (!form.classList || !form.classList.contains("stop-form")) return;
e.preventDefault();
var btn = form.querySelector("button");
if (btn) btn.disabled = true;
fetch(form.action, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(new FormData(form)),
credentials: "same-origin",
}).catch(function () {
if (btn) btn.disabled = false;
});
});
/* ------------------------------------------------------------------
* Thinking indicator: time it, then collapse it once the reply starts.
*
* The card is rendered open by default (see chat_response.html) so
* reasoning is visible live, the same way it streams in before the
* answer on claude.ai — no JS needed for that part. What JS adds is
* purely cosmetic: once the reply actually begins, collapse the card
* into a "Thought for Ns" summary instead of leaving it expanded
* under the finished answer. htmx-ext-sse fires a bubbling
* "htmx:sseBeforeMessage" for every named SSE event it receives, with
* detail.type set to that event's name ("thinking", "token", "done",
* "tool") and evt.target set to whichever sse-swap element matched —
* we don't touch the actual content swap here, htmx-ext-sse already
* does that; this only reads the event to time things.
* ------------------------------------------------------------------ */
var thinkingStartedAt = new WeakMap();
document.body.addEventListener("htmx:sseBeforeMessage", function (e) {
var type = e.detail && e.detail.type;
if (type !== "thinking" && type !== "token" && type !== "done") return;
var row = e.target.closest && e.target.closest(".message-row");
var card = row && row.querySelector(".thinking-card");
if (!card || card.dataset.settled) return;
if (type === "thinking") {
if (!thinkingStartedAt.has(card)) thinkingStartedAt.set(card, Date.now());
return;
}
// First "token" or "done" seen for this row: settle the card once,
// whether or not it ever actually thought out loud.
card.dataset.settled = "1";
if (thinkingStartedAt.has(card)) {
var seconds = Math.max(1, Math.round((Date.now() - thinkingStartedAt.get(card)) / 1000));
var label = card.querySelector(".thinking-elapsed");
if (label) label.textContent = "\u00B7 Thought for " + seconds + "s";
card.open = false;
}
});
/* ------------------------------------------------------------------
* Composer attachments — "Add files or photos", strongly inspired by
* Claude Code's chat input: click the paperclip, drop a file anywhere
* on the page (dropping right on the message box highlights it, like
* claude.ai), or paste a screenshot, and it becomes a chip in the tray
* above the textarea. Each chip uploads in the background the moment
* it appears — one fetch per file, to POST /files/attach (see
* internal/handlers/attachments.go) — so sending the message is just
* the composer form's normal htmx POST /chat; the uploaded workspace
* path already rides along as a hidden input (see addHiddenInput
* below). Remove a chip before sending to drop it from the message;
* the file stays in the workspace (nothing currently reaps unattached
* uploads — a known, accepted gap, same spirit as the ones called out
* in ARCHITECTURE.md).
* ------------------------------------------------------------------ */
var tray = document.getElementById("composer-attachments");
var attachFileInput = document.getElementById("composer-file-input");
var attachBtn = document.getElementById("attach-btn");
var composerForm = input && input.closest("form");
var attachments = Object.create(null); // clientId -> { status, path, controller }
var attachSeq = 0;
var maxAttachments = 8; // mirrors maxChatAttachments server-side
function toast(msg, isError) {
var el = document.createElement("div");
el.className = "drop-toast" + (isError ? " is-error" : "");
el.setAttribute("role", "status");
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.classList.add("is-leaving"); }, 3200);
setTimeout(function () { el.remove(); }, 3700);
}
function attachmentsReadyCount() {
var n = 0;
for (var id in attachments) if (attachments[id].status === "done") n++;
return n;
}
function attachmentsPendingCount() {
var n = 0;
for (var id in attachments) if (attachments[id].status === "uploading") n++;
return n;
}
function attachmentsCount() {
return Object.keys(attachments).length;
}
function fileIconSVG() {
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/>' +
'<path d="M14 3v5h5"/></svg>';
}
// Shown for a .zip while it's uploading/extracting, and again on the
// sent message's chip (see attachments.html) once the server confirms
// it really was one — see isZipName / markChipAsArchive below.
function archiveIconSVG() {
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
}
function isZipName(name) {
return /\.zip$/i.test(name || "");
}
// Plain "path" for a regular file; "path|N" for an extracted zip's
// folder, so cleanAttachmentPaths (internal/handlers/attachments.go)
// can tell the two apart when the composer form is submitted.
function addHiddenInput(id, path, entryCount) {
var el = document.createElement("input");
el.type = "hidden";
el.name = "attachments";
el.value = entryCount ? path + "|" + entryCount : path;
el.dataset.attachId = id;
composerForm.appendChild(el);
}
// Swaps a settled chip's icon for the folder glyph and appends the
// "N files" count once /files/attach confirms the zip was extracted.
function markChipAsArchive(id, entryCount) {
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (!chip) return;
chip.classList.add("is-archive");
var preview = chip.querySelector(".attach-chip-preview");
if (preview) preview.innerHTML = archiveIconSVG();
var name = chip.querySelector(".attach-chip-name");
if (name) name.textContent = name.textContent + " \u00B7 " + entryCount + (entryCount === 1 ? " file" : " files");
}
function removeAttachment(id) {
var rec = attachments[id];
if (!rec) return;
if (rec.controller) rec.controller.abort();
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (chip) chip.remove();
var hidden = composerForm && composerForm.querySelector('input[data-attach-id="' + id + '"]');
if (hidden) hidden.remove();
delete attachments[id];
}
function clearAttachments() {
for (var id in attachments) removeAttachment(id);
tray.innerHTML = "";
}
function settleChip(id, ok, label) {
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (!chip) return;
chip.classList.remove("is-uploading");
chip.classList.toggle("is-error", !ok);
if (!ok) chip.title = label || "Upload failed \u2014 click to remove";
}
function renderChip(id, file) {
var chip = document.createElement("div");
chip.className = "attach-chip is-uploading";
chip.dataset.attachId = id;
var preview = document.createElement("div");
preview.className = "attach-chip-preview";
if (file.type && file.type.indexOf("image/") === 0 && typeof FileReader === "function") {
// A data: URL, not blob: — the page's img-src CSP (see
// internal/handlers/middleware.go) allows 'self' and data: only, so
// this is the one local-preview mechanism that actually renders.
var reader = new FileReader();
reader.onload = function () {
if (!attachments[id]) return; // chip was removed before this fired
var img = document.createElement("img");
img.src = reader.result;
img.alt = "";
preview.insertBefore(img, preview.firstChild);
};
reader.readAsDataURL(file);
} else if (isZipName(file.name)) {
preview.innerHTML = archiveIconSVG();
} else {
preview.innerHTML = fileIconSVG();
}
var spinner = document.createElement("span");
spinner.className = "attach-chip-spinner";
spinner.setAttribute("aria-hidden", "true");
preview.appendChild(spinner);
var name = document.createElement("span");
name.className = "attach-chip-name";
name.textContent = file.name;
var remove = document.createElement("button");
remove.type = "button";
remove.className = "attach-chip-remove";
remove.setAttribute("aria-label", "Remove " + file.name);
remove.textContent = "\u00D7";
remove.addEventListener("click", function (ev) {
ev.preventDefault();
removeAttachment(id);
});
chip.appendChild(preview);
chip.appendChild(name);
chip.appendChild(remove);
tray.appendChild(chip);
}
function uploadAttachment(id, file) {
var rec = attachments[id];
var controller = typeof AbortController === "function" ? new AbortController() : null;
if (controller) rec.controller = controller;
var body = new FormData();
body.append("csrf_token", composerForm.querySelector('input[name="csrf_token"]').value);
body.append("conversation_id", composerForm.querySelector('input[name="conversation_id"]').value);
body.append("files", file);
fetch("/files/attach", {
method: "POST",
body: body,
credentials: "same-origin",
signal: controller ? controller.signal : undefined,
})
.then(function (res) {
return res.json().then(function (data) { return { ok: res.ok, data: data }; });
})
.then(function (result) {
if (!attachments[id]) return; // removed while in flight
if (!result.ok) {
rec.status = "error";
settleChip(id, false, (result.data && result.data.error) || "Upload failed");
toast((result.data && result.data.error) || ("Couldn\u2019t attach " + file.name + "."), true);
return;
}
rec.status = "done";
rec.path = result.data.path;
addHiddenInput(id, result.data.path, result.data.entryCount);
if (result.data.isArchive) markChipAsArchive(id, result.data.entryCount);
settleChip(id, true);
})
.catch(function (err) {
if (!attachments[id] || (err && err.name === "AbortError")) return;
rec.status = "error";
settleChip(id, false, "Upload failed");
toast("Couldn\u2019t attach " + file.name + ".", true);
});
}
function stageFiles(fileList) {
if (!tray || !composerForm) return;
var files = Array.prototype.slice.call(fileList || []);
if (!files.length) return;
var room = maxAttachments - attachmentsCount();
if (room <= 0) {
toast("Up to " + maxAttachments + " files per message.", true);
return;
}
if (files.length > room) {
toast("Only the first " + room + " file" + (room === 1 ? "" : "s") + " were added (limit " + maxAttachments + ").", true);
files = files.slice(0, room);
}
files.forEach(function (file) {
var id = "a" + (++attachSeq);
attachments[id] = { status: "uploading" };
renderChip(id, file);
uploadAttachment(id, file);
});
}
if (attachBtn && attachFileInput) {
attachBtn.addEventListener("click", function () { attachFileInput.click(); });
attachFileInput.addEventListener("change", function () {
stageFiles(attachFileInput.files);
attachFileInput.value = ""; // allow re-picking the same file later
});
}
/* ---- Drag & drop (anywhere on the page) + paste ---- */
var overlay = null;
var dragDepth = 0;
function overComposer(e) {
return !!(composerForm && e.target && composerForm.contains(e.target));
}
function hasFiles(e) {
var t = e.dataTransfer && e.dataTransfer.types;
if (!t) return false;
for (var i = 0; i < t.length; i++) if (t[i] === "Files") return true;
return false;
}
function showOverlay(on) {
if (!overlay) {
overlay = document.createElement("div");
overlay.className = "drop-overlay";
overlay.setAttribute("aria-hidden", "true");
overlay.innerHTML = '<div class="drop-overlay-box"><strong>Drop files to attach</strong>' +
"<span>They\u2019ll be added to your next message</span></div>";
document.body.appendChild(overlay);
}
overlay.classList.toggle("is-visible", on);
}
document.addEventListener("dragenter", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth++;
showOverlay(true);
});
document.addEventListener("dragover", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
// Hovering the message box: highlight it instead of the page overlay.
document.body.classList.toggle("drag-composer", overComposer(e));
});
document.addEventListener("dragleave", function (e) {
if (!hasFiles(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
showOverlay(false);
document.body.classList.remove("drag-composer");
}
});
document.addEventListener("drop", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth = 0;
showOverlay(false);
document.body.classList.remove("drag-composer");
if (e.dataTransfer.files && e.dataTransfer.files.length) stageFiles(e.dataTransfer.files);
});
// Pasting a file (e.g. a screenshot) into the message box attaches it too.
if (input) {
input.addEventListener("paste", function (e) {
var files = e.clipboardData && e.clipboardData.files;
if (!files || !files.length) return;
e.preventDefault();
stageFiles(files);
});
}
/* ------------------------------------------------------------------
* Send-button state: dim "Send" while there is nothing to send. Purely
* visual (data-empty on the form); the server still validates.
* ------------------------------------------------------------------ */
function syncSendState() {
if (composerForm) composerForm.setAttribute("data-empty", canSend() ? "false" : "true");
}
if (composerForm && input) {
input.addEventListener("input", syncSendState);
new MutationObserver(syncSendState).observe(composerForm, { childList: true });
if (tray) new MutationObserver(syncSendState).observe(tray, { childList: true });
document.body.addEventListener("htmx:afterRequest", function () { setTimeout(syncSendState, 0); });
syncSendState();
}
/* ------------------------------------------------------------------
* Empty-state starters and "More to explore" chips. A chip with
* data-send="true" is self-contained and submits straight away; the
* rest drop their text into the composer for the person to finish.
* ------------------------------------------------------------------ */
document.addEventListener("click", function (e) {
var el = e.target.closest && e.target.closest("[data-prompt]");
if (!el || !input) return;
input.value = el.getAttribute("data-prompt");
resizeInput();
syncSendState();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
if (el.getAttribute("data-send") === "true" && composerForm) composerForm.requestSubmit();
});
/* ------------------------------------------------------------------
* Command palette (Cmd/Ctrl+K or the top-bar search): jump to a
* conversation or run a quick action. Everything it lists is read from
* the sidebar DOM, so there is no new endpoint and it works offline.
* ------------------------------------------------------------------ */
(function palette() {
var trigger = document.getElementById("cmd-trigger");
var overlay, field, list, items = [], selected = 0;
function collect() {
var out = [];
var links = document.querySelectorAll(".conversation-link");
for (var i = 0; i < links.length; i++) {
out.push({ group: "Conversations", title: links[i].textContent.trim(), href: links[i].getAttribute("href"), glyph: "--i-file" });
}
out.push({ group: "Actions", title: "New chat", hint: "Start fresh", glyph: "--i-spark", run: function () {
var f = document.querySelector(".new-chat-form"); if (f) f.submit();
} });
out.push({ group: "Actions", title: "Manage subscription", href: "/subscription", glyph: "--i-cog" });
out.push({ group: "Actions", title: "Attach files", hint: "Add to next message", glyph: "--i-folder", run: function () {
if (attachBtn) attachBtn.click();
} });
return out;
}
function build() {
overlay = document.createElement("div");
overlay.className = "palette-overlay";
overlay.innerHTML =
'<div class="palette" role="dialog" aria-modal="true" aria-label="Search">' +
'<div class="palette-input-row"><span class="glyph" aria-hidden="true"></span>' +
'<input class="palette-input" type="text" placeholder="Search chats and actions\u2026" aria-label="Search" autocomplete="off" spellcheck="false" /></div>' +
'<div class="palette-list" role="listbox"></div>' +
'<div class="palette-foot"><span><kbd>\u2191</kbd> <kbd>\u2193</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Esc</kbd> close</span></div></div>';
document.body.appendChild(overlay);
field = overlay.querySelector(".palette-input");
list = overlay.querySelector(".palette-list");
field.addEventListener("input", function () { selected = 0; render(); });
field.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
else if (e.key === "Enter") { e.preventDefault(); choose(selected); }
});
overlay.addEventListener("mousedown", function (e) { if (e.target === overlay) close(); });
list.addEventListener("click", function (e) {
var row = e.target.closest(".palette-item");
if (row) choose(parseInt(row.getAttribute("data-i"), 10));
});
}
var shown = [];
function render() {
var q = field.value.trim().toLowerCase();
shown = collect().filter(function (it) { return !q || it.title.toLowerCase().indexOf(q) !== -1; });
if (!shown.length) { list.innerHTML = '<div class="palette-empty">No matches</div>'; return; }
var html = "", last = "";
shown.forEach(function (it, i) {
if (it.group !== last) { html += '<div class="palette-group"></div>'; last = it.group; }
html += '<div class="palette-item' + (i === selected ? " is-selected" : "") + '" role="option" data-i="' + i + '">' +
'<span class="glyph" style="--glyph: var(' + it.glyph + ')"></span>' +
'<span class="palette-item-title"></span>' + (it.hint ? '<span class="palette-item-hint"></span>' : "") + "</div>";
});
list.innerHTML = html;
// Fill text via textContent so conversation titles can never inject markup.
var groups = list.querySelectorAll(".palette-group"), g = 0; last = "";
var rows = list.querySelectorAll(".palette-item");
shown.forEach(function (it, i) {
if (it.group !== last) { groups[g++].textContent = it.group; last = it.group; }
rows[i].querySelector(".palette-item-title").textContent = it.title;
var h = rows[i].querySelector(".palette-item-hint"); if (h) h.textContent = it.hint;
});
}
function move(d) {
if (!shown.length) return;
selected = (selected + d + shown.length) % shown.length;
render();
var el = list.querySelector(".is-selected"); if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
}
function choose(i) {
var it = shown[i]; if (!it) return;
close();
if (it.run) it.run(); else if (it.href) window.location.href = it.href;
}
function open() {
if (!overlay) build();
overlay.classList.add("is-open");
field.value = ""; selected = 0; render(); field.focus();
}
function close() {
if (overlay) overlay.classList.remove("is-open");
if (input) input.focus();
}
if (trigger) trigger.addEventListener("click", open);
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
overlay && overlay.classList.contains("is-open") ? close() : open();
} else if (e.key === "Escape" && overlay && overlay.classList.contains("is-open")) {
close();
}
});
})();
})();

View File

@@ -0,0 +1,54 @@
// Show/hide ("eye") toggle for every <input type="password">, wrapped
// in a .password-field div in web/templates/auth.html (login, signup,
// reset_password — the only pages with password fields). Framework-free
// and loaded as an external file on purpose: the page's CSP is
// script-src 'self' only, so no inline <script> or onclick="" handlers.
(function () {
function eyeIcon() {
// Open eye — shown while the field is masked (click to reveal).
return (
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M1.5 12S5 5 12 5s10.5 7 10.5 7-3.5 7-10.5 7S1.5 12 1.5 12Z"/>' +
'<circle cx="12" cy="12" r="3"/>' +
"</svg>"
);
}
function eyeOffIcon() {
// Crossed-out eye — shown while the field is revealed (click to mask).
return (
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19.5C5 19.5 1.5 12.5 1.5 12.5a19 19 0 0 1 4.22-5.06M9.9 4.74A10.6 10.6 0 0 1 12 4.5c7 0 10.5 7 10.5 7a18.9 18.9 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/>' +
'<path d="M1 1l22 22"/>' +
"</svg>"
);
}
function addToggle(field) {
var input = field.querySelector('input[type="password"], input[type="text"]');
if (!input) return;
var btn = document.createElement("button");
btn.type = "button";
btn.className = "password-toggle";
btn.setAttribute("aria-label", "Show password");
btn.setAttribute("aria-pressed", "false");
btn.innerHTML = eyeIcon();
btn.addEventListener("click", function () {
var revealed = input.type === "text";
input.type = revealed ? "password" : "text";
btn.setAttribute("aria-pressed", String(!revealed));
btn.setAttribute("aria-label", revealed ? "Show password" : "Hide password");
btn.innerHTML = revealed ? eyeIcon() : eyeOffIcon();
// Keep focus (and the caret position) in the field itself, not the button.
input.focus({ preventScroll: true });
});
field.appendChild(btn);
}
document.querySelectorAll(".password-field").forEach(addToggle);
})();

View File

@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
{{/*
The small gallery of files attached to a user message (see
internal/handlers/attachments.go's renderUserMessage, which appends
this after the escaped prompt text — the same trick
deliverables.go uses to append download cards to an assistant
reply). Data: []attachmentChipView. An archive chip (IsArchive) has
no DownloadURL — it's a folder of extracted files, not one file —
so it renders as a plain <span>, not a link.
*/}}
{{ define "message_attachments" }}
<div class="msg-attachments">
{{ range . }}
{{ if .IsArchive }}
<span class="msg-attachment is-archive" title="{{ .Name }}">
<span class="msg-attachment-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
</span>
<span class="msg-attachment-name">{{ .Name }} &middot; {{ .EntryCount }} file{{ if ne .EntryCount 1 }}s{{ end }}</span>
</span>
{{ else }}
<a class="msg-attachment" href="{{ .DownloadURL }}" download title="{{ .Name }}">
{{ if .IsImage }}
<img class="msg-attachment-thumb" src="{{ .DownloadURL }}" alt="{{ .Name }}" loading="lazy" />
{{ else }}
<span class="msg-attachment-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>
</span>
{{ end }}
<span class="msg-attachment-name">{{ .Name }}</span>
</a>
{{ end }}
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,245 @@
{{ define "login" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.login_title" }} — Enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/login") }}
<h2>{{ t .Lang "auth.login_title" }}</h2>
<p class="auth-subtitle">Welcome back.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="email">{{ t .Lang "auth.email_label" }}</label>
<input id="email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required autofocus />
</div>
<div class="auth-field">
<label for="password">{{ t .Lang "auth.password_label" }}</label>
<div class="password-field">
<input id="password" type="password" name="password" autocomplete="current-password" required />
</div>
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.login_button" }}</button>
</form>
<div class="auth-footer"><a href="/forgot-password">{{ t .Lang "auth.forgot_link" }}</a></div>
<div class="auth-footer">{{ t .Lang "auth.no_account" }} <a href="/signup">{{ t .Lang "auth.signup_link" }}</a></div>
</div>
</div>
<script src="/static/js/password-toggle.js" defer></script>
</body>
</html>
{{ end }}
{{ define "signup" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<title>{{ t .Lang "auth.signup_title" }} — Enkly</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/signup") }}
<h2>{{ t .Lang "auth.signup_title" }}</h2>
<p class="auth-subtitle">We'll email you a 6-digit code to verify it.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/signup">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="email">{{ t .Lang "auth.email_label" }}</label>
<input id="email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required autofocus />
</div>
<div class="auth-field">
<label for="password">{{ t .Lang "auth.password_label" }}</label>
<div class="password-field">
<input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required />
</div>
</div>
<div class="auth-field">
<label for="password_confirm">{{ t .Lang "auth.confirm_password_label" }}</label>
<div class="password-field">
<input id="password_confirm" type="password" name="password_confirm" autocomplete="new-password" minlength="8" required />
</div>
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.signup_button" }}</button>
</form>
<div class="auth-footer">{{ t .Lang "auth.have_account" }} <a href="/login">{{ t .Lang "auth.login_link" }}</a></div>
</div>
</div>
<script src="/static/js/password-toggle.js" defer></script>
</body>
</html>
{{ end }}
{{ define "verify_email" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.verify_title" }} — enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/verify-email") }}
<h2>{{ t .Lang "auth.verify_title" }}</h2>
<p class="auth-subtitle">Enter the 6-digit code we emailed you.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/verify-email">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="verify-email-address">{{ t .Lang "auth.email_label" }}</label>
<input id="verify-email-address" type="email" name="email" value="{{ .Email }}" autocomplete="email" required />
</div>
<div class="auth-field">
<label for="verify-code">{{ t .Lang "auth.verify_code_label" }}</label>
<input id="verify-code" type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
autocomplete="one-time-code" placeholder="123456" required />
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.verify_button" }}</button>
</form>
<form class="auth-form auth-inline-form" method="post" action="/resend-verification">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="resend-email">Didn't get a code?</label>
<input id="resend-email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required />
</div>
<button class="auth-btn auth-btn-secondary" type="submit">{{ t .Lang "auth.resend_link" }}</button>
</form>
<div class="auth-footer">
Already verified? <a href="/login">{{ t .Lang "auth.login_link" }}</a>
</div>
</div>
</div>
</body>
</html>
{{ end }}
{{ define "forgot_password" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.forgot_title" }} — Enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/forgot-password") }}
<h2>{{ t .Lang "auth.forgot_title" }}</h2>
<p class="auth-subtitle">Enter your email and we'll send you a reset link.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form auth-inline-form" method="post" action="/forgot-password">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<div class="auth-field">
<label for="forgot-email">{{ t .Lang "auth.email_label" }}</label>
<input id="forgot-email" type="email" name="email" value="{{ .Email }}" autocomplete="email" required autofocus />
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.forgot_button" }}</button>
</form>
<div class="auth-footer"><a href="/login">{{ t .Lang "auth.back_to_login" }}</a></div>
</div>
</div>
</body>
</html>
{{ end }}
{{ define "reset_password" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ t .Lang "auth.reset_title" }} — enkly</title>
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="auth-shell">
<div class="auth-card">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" class="logo-mark" />
<h1 class="sr-only">enkly</h1>
</div>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" "/reset-password") }}
<h2>{{ t .Lang "auth.reset_title" }}</h2>
<p class="auth-subtitle">Choose a new password for your account.</p>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Info }}<div class="auth-message info">{{ .Info }}</div>{{ end }}
<form class="auth-form" method="post" action="/reset-password">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="token" value="{{ .Token }}" />
<div class="auth-field">
<label for="password">New password</label>
<div class="password-field">
<input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required autofocus />
</div>
</div>
<div class="auth-field">
<label for="password_confirm">{{ t .Lang "auth.confirm_password_label" }}</label>
<div class="password-field">
<input id="password_confirm" type="password" name="password_confirm" autocomplete="new-password" minlength="8" required />
</div>
</div>
<button class="auth-btn" type="submit">{{ t .Lang "auth.reset_button" }}</button>
</form>
<div class="auth-footer"><a href="/login">{{ t .Lang "auth.back_to_login" }}</a></div>
</div>
</div>
<script src="/static/js/password-toggle.js" defer></script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,115 @@
{{ define "chat_response" }}
<!-- The user's message, appended immediately (no streaming needed). -->
<div class="message-row role-user">
<div class="message-content">
<div class="bubble">{{ .UserPromptHTML }}</div>
</div>
</div>
{{ template "assistant_stream" . }}
{{ end }}
{{/*
The assistant's reply streams in over Server-Sent Events using the htmx
SSE extension (htmx-ext-sse). Shared by "chat_response" (a reply that just
started) and "index" (re-attaching to a run that is still in flight after a
page reload). Data: any value with .StreamID and .CSRFToken.
Event flow (see internal/handlers/chat.go StreamChat):
"tool" -> a fragment wrapped in hx-swap-oob="beforebegin:#assistant-row-<id>"
so each tool-call card is inserted ABOVE this row, in the order
the agent ran them, while the bubble below keeps streaming.
"deliverable" -> a Download-button card for a file a tool call just created
or changed (create_document, edit_spreadsheet, ...), sent right
after that tool call's "tool" event rather than waiting for
"done" — same beforebegin OOB placement, so the button shows up
as soon as the document exists even if the agent keeps working
afterwards. See handlers.liveDeliverable / message.html's
"deliverable_oob".
"thinking_round" -> same OOB-insertion mechanism as "tool", one per
completed round's own reasoning trace (see
agentrun.Callbacks.OnThinkingRound) — gives each round's
thinking its own card, positioned right before the tool
card it led to, instead of only ever being visible as
the single live "thinking" blob below.
"thinking" -> the model's reasoning so far, shown live in an
auto-expanded card (open by default — see .thinking-card
below) so it's visible AS the model thinks, the same way
it streams before the reply on claude.ai. app.js times
how long it stayed open and, on the first "token"/"done"
it sees afterwards, collapses the card to a "Thought for
Ns" summary — a pure enhancement: with JS disabled the
card just stays open and unlabeled, still fully readable.
"token" -> the reply so far (HTML-escaped), swapped into the bubble.
"done" -> the final Markdown-rendered reply. sse-close="done" closes the
EventSource; the trailing .stream-done marker hides Stop and
(via CSS :has()) stops the thinking icon's pulse animation.
*/}}
{{ define "assistant_stream" }}
<div
class="message-row role-assistant"
id="assistant-row-{{ .StreamID }}"
hx-ext="sse"
sse-connect="/chat/stream/{{ .StreamID }}"
sse-close="done"
data-stream-id="{{ .StreamID }}"
>
<span class="avatar avatar-assistant" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 21c1.2-2.5 1.5-5.5 1.3-8.5-.5-1.2-1.2-2.4-2.1-3.5C3.5 9 2 7.8 2 6.2 2 4.6 3.5 3.5 5.2 3.5c.8 0 1.5.3 2.1.8C8.5 3 10.1 2.2 12 2.2c1.9 0 3.5.8 4.7 2.1.6-.5 1.3-.8 2.1-.8 1.7 0 3.2 1.1 3.2 2.7 0 1.6-1.5 2.8-3.2 2.8-.9 1.1-1.6 2.3-2.1 3.5-.2 3 .1 6 1.3 8.5H6Zm4-12c.5 1.8 1.2 3.5 2 3.5s1.5-1.7 2-3.5h-4Z"/>
</svg>
</span>
<div class="message-content">
<div class="message-meta">
<span class="meta-name">enkly</span>
<form method="post" action="/chat/stream/{{ .StreamID }}/stop" class="stop-form" data-role="stop-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button type="submit" class="stop-btn" aria-label="Stop generating">
<span class="stop-icon" aria-hidden="true"></span> Stop
</button>
</form>
</div>
<details class="thinking-card" open>
<summary>
<span class="thinking-icon" aria-hidden="true"></span>
<span class="thinking-label">Thinking</span>
<span class="thinking-elapsed"></span>
</summary>
<div class="thinking-body" sse-swap="thinking" hx-swap="innerHTML"></div>
</details>
<div
class="bubble"
id="assistant-bubble-{{ .StreamID }}"
sse-swap="token,done"
hx-swap="innerHTML"
>
<span class="typing-dots" aria-label="enkly is responding"><span></span><span></span><span></span></span>
</div>
</div>
<!--
"tool" events carry a wrapper with hx-swap-oob (see message.html
"tool_call_oob"). This listener element's own swap is "none" — htmx still
extracts and places the out-of-band content.
-->
<div sse-swap="tool" hx-swap="none" hidden></div>
<!--
"deliverable" events carry a wrapper with hx-swap-oob (see
deliverables.html "deliverable_oob"), same beforebegin placement as
"tool" above. This listener element's own swap is "none" — htmx still
extracts and places the out-of-band content.
-->
<div sse-swap="deliverable" hx-swap="none" hidden></div>
<!--
"thinking_round" events — one per agent round's own standalone
reasoning trace, each its own OOB card (see message.html
"thinking_round_oob") inserted above this row in execution order,
same mechanism as "tool" above. This is in addition to, not a
replacement for, the single live-updating .thinking-card above:
that one shows reasoning AS it streams in for the round currently
in flight; these are the settled, per-step record of every round
once it's done, interleaved with the tool cards the same way
Claude's interleaved thinking blocks sit next to the tool use they
preceded.
-->
<div sse-swap="thinking_round" hx-swap="none" hidden></div>
</div>
{{ end }}

View File

@@ -0,0 +1,43 @@
{{/*
Download cards shown at the end of an agent reply (see
internal/handlers/deliverables.go). Data: []deliverableView.
A card with DownloadURL is a real link to a workspace file; one without
is a code-snippet card whose button is handled by app.js.
*/}}
{{ define "deliverables" }}
<div class="deliverables">
{{ range . }}
<div class="deliverable-card">
<div class="deliverable-tile" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h4"/></svg>
</div>
<div class="deliverable-meta">
<div class="deliverable-name" title="{{ .Name }}">{{ .Name }}</div>
<div class="deliverable-type">{{ .TypeLabel }}{{ if .Detail }} · {{ .Detail }}{{ end }}</div>
</div>
{{ if .DownloadURL }}
<a class="deliverable-btn" href="{{ .DownloadURL }}" download>Download</a>
{{ else }}
<button type="button" class="deliverable-btn snippet-download" data-snippet-index="{{ .SnippetIndex }}" data-filename="{{ .Name }}">Download</button>
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{/*
The SSE "deliverable" payload — one produced file's download card,
sent as soon as the tool call that created or changed it completes
(see internal/handlers/chat.go's TurnEvent_ToolCall case and
liveDeliverable), not only once the whole turn finishes. Same
hx-swap-oob mechanism as message.html's "tool_call_oob": "beforebegin"
on the assistant row puts the card above the still-streaming bubble,
right after the tool card that produced it. Data: StreamID, Views
([]deliverableView — always length 1 here, but "deliverables" above
already ranges over a slice, so it's reused as-is).
*/}}
{{ define "deliverable_oob" }}
<div hx-swap-oob="beforebegin:#assistant-row-{{ .StreamID }}">
{{ template "deliverables" .Views }}
</div>
{{ end }}

View File

@@ -0,0 +1,81 @@
{{/*
The "Workspace files" sidebar panel: an upload form plus a listing of
every file currently in the agent's ENKLY_WORKSPACE, each linking to
GET /files/download/{path...}. Rendered three ways, all with the same
data shape (WorkspaceFiles, ConversationID, CSRFToken, UploadMessage,
UploadError — see indexData in pages.go and filesPanelData in
files.go):
1. Inline, as part of the full "index" page on first load.
2. Standalone, by GET /files — a manual "refresh the list" htmx
fragment (e.g. after the agent's own write_file tool created a
new file mid-conversation).
3. Standalone, by POST /files/upload — after saving what was
submitted, with UploadMessage/UploadError set.
Cases 2 and 3 both target and swap this element with
hx-swap="outerHTML", so the response IS the whole panel, including its
own wrapping <div id="workspace-files-panel">.
*/}}
{{ define "workspace_files_panel" }}
<!--<div class="sidebar-section workspace-section" id="workspace-files-panel">
<div class="sidebar-section-title">
<span>Workspace files</span>
<span class="files-title-right">
{{ len .WorkspaceFiles }}
<button
type="button"
class="icon-btn"
title="Refresh"
aria-label="Refresh workspace files"
hx-get="/files?conversation_id={{ .ConversationID }}"
hx-target="#workspace-files-panel"
hx-swap="outerHTML"
>⟳</button>
</span>
</div>
{{ if .UploadError }}<div class="upload-note upload-note-error">{{ .UploadError }}</div>{{ end }}
{{ if .UploadMessage }}<div class="upload-note upload-note-ok">{{ .UploadMessage }}</div>{{ end }}
<form
method="post"
action="/files/upload"
enctype="multipart/form-data"
class="upload-form"
hx-post="/files/upload"
hx-encoding="multipart/form-data"
hx-target="#workspace-files-panel"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="conversation_id" value="{{ .ConversationID }}" />
<input
type="file"
name="files"
id="workspace-upload-input"
class="upload-input"
multiple
required
aria-label="Choose files to upload into the workspace"
/>
<button type="submit" class="upload-btn" title="Upload into the workspace">Upload</button>
</form>
<div class="file-list workspace-file-list">
{{ if .WorkspaceFiles }}
{{ range .WorkspaceFiles }}
<div class="file-item workspace-file-item">
<span class="file-icon">›</span>
<a href="{{ .DownloadURL }}" class="file-download-link" title="Download {{ .Path }}" download>{{ .Path }}</a>
<span class="file-size">{{ .SizeHuman }}</span>
</div>
{{ end }}
{{ else }}
<div class="file-item file-item-empty">
No files in the workspace yet. Upload one, or ask enkly to create one.
</div>
{{ end }}
</div>
</div>-->
{{ end }}

View File

@@ -0,0 +1,268 @@
{{ define "index" }}
<!DOCTYPE html>
<html lang="{{ .Lang }}"{{ if .Theme }} data-theme="{{ .Theme }}"{{ end }}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ .ConversationTitle }} — Enkly</title>
<!--
allowEval:false — htmx otherwise uses eval()/new Function() for hx-on:*
handlers and trigger filters, which the server's Content-Security-Policy
(script-src 'self', no 'unsafe-eval') would block, silently disabling
them. Nothing on this page needs eval. includeIndicatorStyles:false stops
htmx injecting an inline <style> block.
-->
<meta name="htmx-config" content='{"allowEval":false,"includeIndicatorStyles":false}' />
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
<!--
Vendored locally (web/static/js/vendor/, from npm htmx.org@2.0.4 and
htmx-ext-sse@2.2.2) rather than loaded from a CDN. This is
self-hosted software: a script tag pointed at unpkg.com silently
fails with zero on-page indication whenever the host has no
outbound internet access (common for self-hosted/air-gapped
deployments) or that CDN is blocked/slow/down — and when it fails,
every hx-* attribute in this page becomes an inert, do-nothing
HTML attribute, which is exactly why clicking "Send" could
previously appear to do nothing at all. Vendoring removes the
runtime dependency on any third-party host entirely.
-->
<script src="/static/js/vendor/htmx.min.js"></script>
<script src="/static/js/vendor/htmx-sse.js"></script>
</head>
<body>
<!--
CSS-only mobile sidebar toggle: a hidden checkbox plus a <label>
acting as the hamburger button. No JavaScript is needed for this —
see .sidebar-toggle / .sidebar-backdrop in style.css.
-->
<input type="checkbox" id="sidebar-toggle" class="sidebar-toggle-input" hidden />
<div class="app-shell" hx-ext="sse">
<label for="sidebar-toggle" class="sidebar-backdrop"></label>
<!-- ============================= SIDEBAR ============================= -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="brand-lockup">
<img src="/static/img/enkly-logo.png" alt="enkly" width="40" height="40" />
</div>
<label for="sidebar-toggle" class="sidebar-close" aria-label="{{ t .Lang "nav.close_sidebar" }}">×</label>
</div>
<form method="post" action="/conversations/new" class="new-chat-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button type="submit" class="new-chat-btn">
<span class="plus-icon" aria-hidden="true">+</span> {{ t .Lang "nav.new_chat" }}
</button>
</form>
<div class="sidebar-section conversation-section">
<div class="sidebar-section-title"><span>{{ t .Lang "nav.conversations" }}</span></div>
<div class="conversation-list">
{{ if .Conversations }}
{{ range .Conversations }}
<div class="conversation-item {{ if eq .ID $.ConversationID }}active{{ end }}">
<a href="/c/{{ .ID }}" class="conversation-link">{{ .Title }}</a>
<div class="conversation-actions">
<form method="post" action="/conversations/{{ .ID }}/rename" class="rename-form">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}" />
<input type="text" name="title" value="{{ .Title }}" class="rename-input" aria-label="Rename conversation" />
<button type="submit" class="icon-btn" aria-label="{{ t $.Lang "nav.rename_save" }}" title="{{ t $.Lang "nav.rename_save" }}">✓</button>
</form>
<form method="post" action="/conversations/{{ .ID }}/delete" class="delete-form">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}" />
<button type="submit" class="icon-btn icon-btn-danger" aria-label="{{ t $.Lang "nav.delete" }}" title="{{ t $.Lang "nav.delete" }}">×</button>
</form>
</div>
</div>
{{ end }}
{{ else }}
<div class="conversation-item-empty">{{ t .Lang "nav.no_conversations" }}</div>
{{ end }}
</div>
</div>
<!--<div class="sidebar-section files-section">
<div class="sidebar-section-title">
<span>Indexed files</span>
<span>{{ len .Files }}</span>
</div>
<div class="file-list">
{{ if .Files }}
{{ range .Files }}
<div class="file-item">
<span class="file-icon">›</span>
<span>{{ . }}</span>
</div>
{{ end }}
{{ else }}
<div class="file-item file-item-empty">
No files indexed yet. Run the indexer to populate pgvector.
</div>
{{ end }}
</div>
</div>-->
{{ template "explorer_panel" . }}
{{ template "workspace_files_panel" . }}
<div class="sidebar-footer">
<a href="/subscription" class="plan-chip" title="Manage your subscription">
<span class="plan-dot"></span>
<span>{{ .PlanName }} {{ t .Lang "nav.plan_suffix" }}</span>
<span class="plan-chip-manage">{{ t .Lang "nav.manage" }}</span>
</a>
<div class="user-chip">
<span class="user-avatar" aria-hidden="true">{{ if .UserEmail }}{{ slice .UserEmail 0 1 }}{{ end }}</span>
<span class="user-email" title="{{ .UserEmail }}">{{ .UserEmail }}</span>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button class="logout-btn" type="submit">{{ t .Lang "nav.log_out" }}</button>
</form>
</div>
<form method="post" action="/theme" class="theme-toggle" aria-label="Color theme">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="next" value="/c/{{ .ConversationID }}" />
<button type="submit" name="theme" value="light" class="theme-btn{{ if eq .Theme "light" }} is-active{{ end }}" title="Light theme">{{ t .Lang "nav.theme_light" }}</button>
<button type="submit" name="theme" value="dark" class="theme-btn{{ if eq .Theme "dark" }} is-active{{ end }}" title="Dark theme">{{ t .Lang "nav.theme_dark" }}</button>
<button type="submit" name="theme" value="auto" class="theme-btn{{ if not .Theme }} is-active{{ end }}" title="Follow the system setting">{{ t .Lang "nav.theme_auto" }}</button>
</form>
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" (print "/c/" .ConversationID)) }}
<span class="model-tag">local agent · tools enabled</span>
</div>
</aside>
<!-- ============================ MAIN COLUMN =========================== -->
<main class="main-col">
<div class="chat-topbar">
<label for="sidebar-toggle" class="sidebar-open" aria-label="{{ t .Lang "nav.open_sidebar" }}">☰</label>
<h2>{{ .ConversationTitle }}</h2>
<!-- Opens the command palette (app.js): search chats, jump to actions. -->
<button type="button" class="cmd-trigger" id="cmd-trigger" aria-haspopup="dialog" aria-label="Search conversations and actions">
<span class="glyph" aria-hidden="true"></span>
<span class="cmd-trigger-label">{{ t .Lang "nav.search_placeholder" }}</span>
<kbd>⌘K</kbd>
</button>
<span class="model-badge">{{ t .Lang "nav.agent_ready" }}</span>
</div>
<div class="chat-window" id="chat-window">
{{ if .Messages }}
{{ range .Messages }}
{{ template "message" . }}
{{ end }}
{{ with .ActiveStream }}{{ template "assistant_stream" . }}{{ end }}
{{ else }}
<div class="empty-state">
<div class="hero">
<span class="hero-mark" aria-hidden="true"><span class="glyph"></span></span>
<h3>{{ t .Lang "hero.title" }}</h3>
<p>{{ t .Lang "hero.subtitle" }}</p>
</div>
<h4 class="block-title">{{ t .Lang "hero.start_with" }}</h4>
<div class="starters">
<button type="button" class="starter" style="--k: var(--k-read)" data-prompt="Give me a tour of this workspace: the main folders, how they fit together, and where I should start reading." data-send="true">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-folder)"></span></span>
<span class="starter-title">{{ t .Lang "starter.tour_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.tour_desc" }}</span>
</button>
<button type="button" class="starter" style="--k: var(--k-search)" data-prompt="Find where authentication is handled and explain how a request flows through it." data-send="true">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-search)"></span></span>
<span class="starter-title">{{ t .Lang "starter.trace_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.trace_desc" }}</span>
</button>
<button type="button" class="starter" style="--k: var(--k-write)" data-prompt="Fix this bug: ">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-edit)"></span></span>
<span class="starter-title">{{ t .Lang "starter.fix_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.fix_desc" }}</span>
</button>
<button type="button" class="starter" style="--k: var(--k-run)" data-prompt="Run the tests and summarise any failures with a suggested fix for each." data-send="true">
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-term)"></span></span>
<span class="starter-title">{{ t .Lang "starter.tests_title" }}</span>
<span class="starter-desc">{{ t .Lang "starter.tests_desc" }}</span>
</button>
</div>
<h4 class="block-title">{{ t .Lang "hero.tools_heading" }}</h4>
<div class="tools-strip">
<span class="tool-chip" style="--k: var(--k-read)"><span class="glyph" style="--glyph: var(--i-file)"></span>{{ t .Lang "tool.read" }}</span>
<span class="tool-chip" style="--k: var(--k-write)"><span class="glyph" style="--glyph: var(--i-edit)"></span>{{ t .Lang "tool.write" }}</span>
<span class="tool-chip" style="--k: var(--k-search)"><span class="glyph" style="--glyph: var(--i-search)"></span>{{ t .Lang "tool.search" }}</span>
<span class="tool-chip" style="--k: var(--k-run)"><span class="glyph" style="--glyph: var(--i-term)"></span>{{ t .Lang "tool.run" }}</span>
<span class="tool-chip" style="--k: var(--k-agent)"><span class="glyph" style="--glyph: var(--i-spark)"></span>{{ t .Lang "tool.subagents" }}</span>
</div>
<h4 class="block-title">{{ t .Lang "hero.explore_heading" }}</h4>
<div class="explore">
<button type="button" class="explore-chip" data-prompt="Write unit tests for ">{{ t .Lang "explore.tests" }}</button>
<button type="button" class="explore-chip" data-prompt="Review this code for bugs and style issues: ">{{ t .Lang "explore.review" }}</button>
<button type="button" class="explore-chip" data-prompt="Explain what this file does: ">{{ t .Lang "explore.explain" }}</button>
<button type="button" class="explore-chip" data-prompt="Create a Word document summarising ">{{ t .Lang "explore.document" }}</button>
<button type="button" class="explore-chip" data-prompt="Refactor this to be simpler: ">{{ t .Lang "explore.refactor" }}</button>
</div>
</div>
{{ end }}
</div>
<div class="composer">
<!--
Staged-attachment tray: empty (and hidden via .composer-attachments:empty
in style.css) until app.js adds a chip for a picked/dropped/pasted file.
See the "Composer attachments" section of web/static/js/app.js and
internal/handlers/attachments.go.
-->
<div class="composer-attachments" id="composer-attachments" aria-live="polite"></div>
<form
class="composer-form"
data-empty="true"
hx-post="/chat"
hx-target="#chat-window"
hx-swap="beforeend"
>
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="conversation_id" value="{{ .ConversationID }}" />
<!--
Unnamed on purpose: this input is never submitted as part of the
form itself. app.js uploads each selected file individually (over
fetch, to POST /files/attach) the moment it's chosen, then adds a
separate hidden "attachments" input per successful upload below.
-->
<input type="file" id="composer-file-input" class="composer-file-input" multiple hidden tabindex="-1" aria-hidden="true" />
<button type="button" class="attach-btn" id="attach-btn" aria-label="{{ t .Lang "composer.attach" }}" title="{{ t .Lang "composer.attach" }}">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
</button>
<textarea
name="prompt"
id="composer-input"
rows="1"
placeholder="{{ t .Lang "composer.placeholder" }}"
></textarea>
<button class="send-btn" type="submit" aria-label="{{ t .Lang "composer.send" }}">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
<span class="send-btn-label">{{ t .Lang "composer.send" }}</span>
</button>
</form>
<div class="composer-hint">
<span class="hint-kbd"><kbd>Enter</kbd> {{ t .Lang "composer.hint_send" }}</span>
<span class="hint-kbd"><kbd>Shift</kbd><kbd>Enter</kbd> {{ t .Lang "composer.hint_newline" }}</span>
<span>{{ t .Lang "composer.hint_drop" }}</span>
</div>
</div>
<div id="status-bar" class="status-bar" hx-get="/status?conversation_id={{ .ConversationID }}" hx-trigger="load" hx-swap="outerHTML"></div>
</main>
</div>
<!--
Small, same-origin enhancement layer: auto-scroll, auto-resizing
textarea, Enter-to-send, clearing the composer after a send, the copy
button on code blocks, and a fetch-based Stop button. Everything except
sending a chat message (which needs htmx anyway) still works with this
file blocked. See web/static/js/app.js.
-->
<script src="/static/js/app.js"></script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,116 @@
{{/*
Renders a single historical message row. Used for full-page loads.
Tool messages get a collapsible <details> card (native HTML — no JS
needed) instead of a plain bubble, since a run_bash_command or
search_codebase result can be long and isn't usually what you want
to read first when scrolling a past conversation.
*/}}
{{ define "message" }}
{{ if eq .Role "tool" }}
<div class="message-row role-tool">
<details class="tool-card{{ if eq .ToolName "dispatch_subagent" }} is-subagent{{ end }}">
<summary>
<span class="tool-icon tool-icon-{{ .ToolName }}"></span>
<span class="tool-name">{{ .ToolName }}</span>
<span class="tool-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-body">{{ .RenderedHTML }}</div>
</details>
</div>
{{ else if eq .Role "user" }}
<div class="message-row role-user">
<div class="message-content">
<div class="bubble">{{ .RenderedHTML }}</div>
</div>
</div>
{{ else }}
<div class="message-row role-assistant">
<span class="avatar avatar-assistant" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 21c1.2-2.5 1.5-5.5 1.3-8.5-.5-1.2-1.2-2.4-2.1-3.5C3.5 9 2 7.8 2 6.2 2 4.6 3.5 3.5 5.2 3.5c.8 0 1.5.3 2.1.8C8.5 3 10.1 2.2 12 2.2c1.9 0 3.5.8 4.7 2.1.6-.5 1.3-.8 2.1-.8 1.7 0 3.2 1.1 3.2 2.7 0 1.6-1.5 2.8-3.2 2.8-.9 1.1-1.6 2.3-2.1 3.5-.2 3 .1 6 1.3 8.5H6Zm4-12c.5 1.8 1.2 3.5 2 3.5s1.5-1.7 2-3.5h-4Z"/>
</svg>
</span>
<div class="message-content">
<div class="message-meta"><span class="meta-name">enkly</span></div>
<div class="bubble">{{ .RenderedHTML }}</div>
</div>
</div>
{{ end }}
{{ end }}
{{/*
Renders a tool-call card (used for live streaming and by tests). Data:
ToolName, Summary, Body (already-escaped HTML), IsError.
*/}}
{{ define "tool_call" }}
<div class="message-row role-tool">
<details class="tool-card{{ if .IsError }} is-error{{ end }}{{ if eq .ToolName "dispatch_subagent" }} is-subagent{{ end }}">
<summary>
<span class="tool-icon tool-icon-{{ .ToolName }}"></span>
<span class="tool-name">{{ .ToolName }}</span>
<span class="tool-summary">{{ .Summary }}</span>
<span class="tool-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-body">{{ .Body }}</div>
</details>
</div>
{{ end }}
{{/*
The SSE "tool" payload. htmx applies an out-of-band swap by moving the
CONTENT of the hx-swap-oob element (for any strategy other than
outerHTML), so the target-carrying wrapper is a bare <div> and the real
.message-row lives inside it. "beforebegin" on the assistant row puts
each card above the still-streaming bubble, in execution order.
Data: StreamID plus the tool_call fields.
*/}}
{{ define "tool_call_oob" }}
<div hx-swap-oob="beforebegin:#assistant-row-{{ .StreamID }}">
{{ template "tool_call" . }}
</div>
{{ end }}
{{/*
A single round's standalone reasoning trace, rendered as its own
collapsible card — the per-step counterpart to the single
auto-collapsing "thinking-card" in chat_response.html's
assistant_stream, which only ever shows one running cumulative blob.
This one instead gives each round its own card, inserted above the
assistant bubble in execution order via the same "beforebegin"
OOB-swap trick tool_call_oob uses — so a card for round 0's reasoning
lands right before the tool card it led to, then round 1's reasoning
card lands before round 1's tool card, and so on: discrete,
positioned reasoning steps rather than one blob for the whole turn,
the same interleaved-thinking shape Claude's own UI gives a
tool-using turn. Reuses .tool-card's chrome (border, summary, chevron)
with a thinking-specific icon and a round number, rather than
introducing a parallel set of card styles for what is structurally
the same kind of row. Data: Round (int), Thinking (plain text).
*/}}
{{ define "thinking_round" }}
<div class="message-row role-tool">
<details class="tool-card is-thinking-round">
<summary>
<span class="thinking-icon" aria-hidden="true"></span>
<span class="tool-name">Thinking</span>
<span class="tool-summary">round {{ .Round }}</span>
<span class="tool-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-body"><p class="tool-output">{{ .Thinking }}</p></div>
</details>
</div>
{{ end }}
{{/* The SSE "thinking_round" payload — same OOB wrapper as tool_call_oob. */}}
{{ define "thinking_round_oob" }}
<div hx-swap-oob="beforebegin:#assistant-row-{{ .StreamID }}">
{{ template "thinking_round" . }}
</div>
{{ end }}
{{/* Inline error row returned by POST /chat for validation problems. */}}
{{ define "chat_error" }}
<div class="message-row role-system">
<div class="bubble bubble-error">{{ .Message }}</div>
</div>
{{ end }}

View File

@@ -0,0 +1,31 @@
{{/*
lang_switcher — the language switcher, shared by index.html (sidebar
footer, next to the theme toggle) and every auth.html page. Expects
a dict with:
CSRFToken — the page's CSRF token
Lang — the page's current locale code ("en", "fr", …)
Next — where SetLanguage should redirect back to after the
POST; each call site passes its own page path since,
unlike ConversationID for the theme toggle, there's no
single field every page that includes this partial has
in common.
Deliberately plain <select> + submit button, no auto-submit-on-change
— the page's Content-Security-Policy has no 'unsafe-inline' in
script-src (see middleware.go's contentSecurityPolicy), and an
inline onchange="…" handler is script-src territory, not
style-src's, so it would be silently blocked by the browser exactly
the way the project's own htmx allowEval:false note warns about.
Same no-new-script-dependency stance as the rest of the front end.
*/}}
{{ define "lang_switcher" }}
<form method="post" action="/language" class="lang-switcher" aria-label="{{ t .Lang "nav.language" }}">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<input type="hidden" name="next" value="{{ .Next }}" />
<select name="lang" class="lang-select" aria-label="{{ t .Lang "nav.language" }}">
{{ range supportedLanguages }}
<option value="{{ .Code }}"{{ if eq (print .Code) $.Lang }} selected{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
<button type="submit" class="lang-switcher-submit" title="{{ t .Lang "nav.language" }}">✓</button>
</form>
{{ end }}

View File

@@ -0,0 +1,114 @@
{{ define "subscription" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Subscription — enkly</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
<body>
<div class="billing-shell">
<div class="billing-topbar">
<div class="brand-lockup">
<span class="logo-dot"></span>
<h1>enkly</h1>
</div>
<a href="/" class="billing-back">← Back to chat</a>
</div>
<div class="billing-card">
<h2>Subscription</h2>
<p class="auth-subtitle">
You're on the <strong>{{ .CurrentName }}</strong> plan.
{{ if eq .Status "past_due" }}<span class="plan-status plan-status-warn">Payment past due — please update your card.</span>{{ end }}
{{ if eq .Status "canceled" }}<span class="plan-status">Your last paid subscription was cancelled.</span>{{ end }}
{{ if .CancelAtPeriodEnd }}
<span class="plan-status plan-status-warn">
Cancels at the end of the current billing period{{ if not .CurrentPeriodEnd.IsZero }} ({{ .CurrentPeriodEnd.Format "Jan 2, 2006" }}){{ end }}.
</span>
{{ else if not .CurrentPeriodEnd.IsZero }}
<span class="plan-status">Renews {{ .CurrentPeriodEnd.Format "Jan 2, 2006" }}.</span>
{{ end }}
</p>
<div class="usage-card {{ if .AtLimit }}usage-at{{ else if .NearLimit }}usage-near{{ end }}">
<div class="usage-head">
<span>Today's messages</span>
{{ if .Unlimited }}
<span><strong>{{ .UsedToday }}</strong> sent · unlimited</span>
{{ else }}
<span><strong>{{ .UsedToday }}</strong> / {{ .DailyLimit }}</span>
{{ end }}
</div>
{{ if not .Unlimited }}
<div class="usage-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ .UsagePercent }}">
<div class="usage-fill" style="width: {{ .UsagePercent }}%"></div>
</div>
{{ end }}
{{ if .AtLimit }}
<p class="usage-note">You've used all of today's messages. Upgrade below to keep chatting — the count resets daily (UTC).</p>
{{ else if .NearLimit }}
<p class="usage-note">You're close to today's limit. Upgrade below for more room.</p>
{{ end }}
</div>
{{ if .Error }}<div class="auth-message error">{{ .Error }}</div>{{ end }}
{{ if .Message }}<div class="auth-message info">{{ .Message }}</div>{{ end }}
<div class="plan-grid">
{{ $csrf := .CSRFToken }}
{{ range .Plans }}
<div class="plan-card {{ if .IsCurrent }}plan-card-current{{ end }}{{ if .Popular }} plan-card-popular{{ end }}">
{{ if .IsCurrent }}<div class="plan-badge">Current plan</div>{{ else if .Popular }}<div class="plan-badge plan-badge-popular">Most popular</div>{{ end }}
<div class="plan-card-name">{{ .Name }}</div>
<div class="plan-card-price">
{{ if eq .PriceEUR 0 }}€0{{ else }}€{{ .PriceEUR }}{{ end }}
<span class="plan-card-period">/month</span>
</div>
<ul class="plan-card-features">
{{ if eq .DailyMessageLimit 0 }}
<li>Unlimited messages/day</li>
{{ else }}
<li>{{ .DailyMessageLimit }} messages/day</li>
{{ end }}
{{ if .Multiplier }}<li class="plan-perk">{{ .Multiplier }}</li>{{ end }}
<li>Full tool-using agent access</li>
<li>File upload &amp; download</li>
</ul>
{{ if .IsCurrent }}
<button class="auth-btn plan-btn" type="button" disabled>Current plan</button>
{{ else if not .Checkoutable }}
<button class="auth-btn plan-btn auth-btn-secondary" type="button" disabled title="{{ .UnavailableReason }}">Unavailable</button>
{{ if .UnavailableReason }}<p class="plan-unavailable-reason">{{ .UnavailableReason }}</p>{{ end }}
{{ else }}
<form method="post" action="/subscription/checkout" class="plan-form">
<input type="hidden" name="csrf_token" value="{{ $csrf }}" />
<input type="hidden" name="plan" value="{{ .Plan }}" />
<button class="auth-btn plan-btn" type="submit">
{{ .Action }}
</button>
</form>
{{ end }}
</div>
{{ end }}
</div>
{{ if .HasBillingPortal }}
<form method="post" action="/subscription/portal" class="billing-portal-form">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
<button class="auth-btn auth-btn-secondary" type="submit">Manage billing &amp; invoices</button>
</form>
{{ end }}
<p class="billing-footnote">
Plans renew monthly and you can cancel any time from “Manage billing &amp; invoices” — you keep your plan until the end of the period you paid for.<br />
Payments are processed by Stripe. enkly never sees or stores your card details.
</p>
</div>
</div>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,38 @@
{{/*
File explorer fragments, served by GET /tree (internal/handlers/ui.go).
Deliberately non-recursive: a directory is fetched one level at a time,
so an open directory's children are always files or COLLAPSED
directories. That matches the lazy-loading behavior (nothing below a
closed folder is ever read) and keeps html/template out of recursive
template definitions.
*/}}
{{ define "tree_root" }}<ul class="tree-list tree-root">{{ range .Children }}{{ template "tree_entry" . }}{{ else }}<li class="tree-empty">The workspace is empty.</li>{{ end }}</ul>{{ end }}
{{ define "tree_entry" }}{{ if .IsDir }}{{ template "tree_dir_collapsed" . }}{{ else }}{{ template "tree_file" . }}{{ end }}{{ end }}
{{ define "tree_file" }}<li class="tree-file"><a href="{{ .DownloadURL }}" class="tree-node tree-file-link" title="Download {{ .Path }}" download><span class="tree-icon">·</span>{{ .Name }}</a><span class="file-size">{{ .SizeHuman }}</span></li>{{ end }}
{{ define "tree_dir_collapsed" }}<li class="tree-dir"><button type="button" class="tree-node" hx-get="{{ .ExpandURL }}" hx-target="closest li" hx-swap="outerHTML" aria-expanded="false"><span class="tree-icon">▸</span>{{ .Name }}</button></li>{{ end }}
{{ define "tree_dir_open" }}<li class="tree-dir is-open"><button type="button" class="tree-node" hx-get="{{ .CollapseURL }}" hx-target="closest li" hx-swap="outerHTML" aria-expanded="true"><span class="tree-icon">▾</span>{{ .Name }}</button><ul class="tree-list">{{ range .Children }}{{ template "tree_entry" . }}{{ else }}<li class="tree-empty">empty</li>{{ end }}</ul></li>{{ end }}
{{ define "tree_dir" }}{{ if .Open }}{{ template "tree_dir_open" . }}{{ else }}{{ template "tree_dir_collapsed" . }}{{ end }}{{ end }}
{{ define "explorer_panel" }}
<div class="sidebar-section explorer-section">
<div class="sidebar-section-title">
<span>Explorer</span>
<button type="button" class="icon-btn" title="Refresh" aria-label="Refresh file tree"
hx-get="/tree" hx-target="#file-tree" hx-swap="innerHTML">⟳</button>
</div>
<div id="file-tree" class="file-tree" hx-get="/tree" hx-trigger="load" hx-swap="innerHTML">
<div class="file-item file-item-empty">Loading…</div>
</div>
</div>
{{ end }}
{{ define "status_bar" }}<div class="status-bar" id="status-bar" hx-get="/status?conversation_id={{ .ConversationID }}" hx-trigger="every 20s" hx-swap="outerHTML">
<span class="status-item" title="Active vLLM chat model"><span class="dot {{ if .VLLMOK }}ok{{ else }}bad{{ end }}"></span>{{ .Model }}</span>
<!--<span class="status-item" title="Postgres connection"><span class="dot {{ if .PostgresOK }}ok{{ else }}bad{{ end }}"></span>postgres</span>-->
<span class="status-item" title="Estimated from message length (chars/4), not the model's tokenizer">Context {{ .ContextTokens }}{{ if .ContextLimit }} / {{ .ContextLimit }} tokens{{ else }} tokens{{ end }}{{ if .ContextLimit }}<span class="ctx-meter{{ if ge .ContextPct 90 }} is-full{{ else if ge .ContextPct 70 }} is-high{{ end }}" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ .ContextPct }}"><span style="width: {{ .ContextPct }}%"></span></span>{{ end }}</span>
</div>{{ end }}

28
services/iam/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Build context is the repo root (see docker-compose.yml) — every
# service's Dockerfile follows this same two-stage shape: compile one
# cmd/server binary against the whole monorepo module, then ship just
# the binary + that service's migrations in a distroless-ish runtime
# image. See ARCHITECTURE.md's "One Go module, many deployables" note
# for why this is a single `module enkly` rather than N go.mod files.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/iam/ services/iam/
# No go.sum is committed yet (see "go mod tidy" in the repo root's
# README), so resolve+lock the full dependency graph here, after all
# source is present — `go mod tidy` needs to see every import (direct
# AND the ones grpc/protobuf/etc. pull in transitively) to compute it.
# Once a go.sum is committed to the repo, this can be replaced with
# the faster, better-cached `go mod download`.
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/iam-server ./services/iam/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/iam-server ./iam-server
COPY --from=build /src/services/iam/migrations ./migrations
ENV ENKLY_IAM_GRPC_ADDR=:9001
EXPOSE 9001
ENTRYPOINT ["./iam-server"]

View File

@@ -0,0 +1,132 @@
// Command server runs the iam microservice: a gRPC server exposing
// api/proto/iam/v1 over ENKLY_IAM_GRPC_ADDR, backed by its own Postgres
// database. Replaces the auth-related slice of the monolith's
// cmd/server/main.go + internal/handlers/auth.go.
package main
import (
"context"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/authsupport"
"enkly/pkg/envkit"
"enkly/pkg/eventbus"
"enkly/pkg/pgkit"
"enkly/services/iam/internal/application"
"enkly/services/iam/internal/infrastructure/postgres"
grpcsrv "enkly/services/iam/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_IAM_GRPC_ADDR", ":9001")
postgresDSN = envkit.String("ENKLY_IAM_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_iam?sslmode=disable")
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
baseURL = envkit.String("ENKLY_BASE_URL", "http://localhost:8080")
migrationsPath = envkit.String("ENKLY_IAM_MIGRATIONS", "migrations/0001_init.sql")
smtpUsername = envkit.String("ENKLY_SMTP_USERNAME", "")
smtpPassword = envkit.String("ENKLY_SMTP_PASSWORD", "")
)
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{
Users: postgres.NewUserRepo(db),
Sessions: postgres.NewSessionRepo(db),
Verification: postgres.NewVerificationRepo(db),
PasswordReset: postgres.NewPasswordResetRepo(db),
Events: bus,
Logger: logger,
BaseURL: baseURL,
MailConfigured: smtpUsername != "" && smtpPassword != "",
// Same windows/backoffs as the monolith's cmd/server/main.go —
// progressive delay, not a hard lockout; see authsupport.Limiter.
LoginLimiter: authsupport.NewLimiter(15*time.Minute, 500*time.Millisecond, 8*time.Second),
ResendLimiter: authsupport.NewLimiter(1*time.Hour, 2*time.Second, 30*time.Second),
VerifyLimiter: authsupport.NewLimiter(15*time.Minute, 500*time.Millisecond, 8*time.Second),
}
// Periodic cleanup of expired sessions/codes/tokens — replaces the
// goroutine cmd/server/main.go started around db.CleanupExpired.
go func() {
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for range t.C {
n, err := postgres.CleanupExpired(context.Background(), db)
if err != nil {
logger.Error("cleanup expired", "err", err)
continue
}
if n > 0 {
logger.Info("cleanup expired", "rows_deleted", n)
}
}
}()
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
iamv1.RegisterIAMServiceServer(grpcServer, grpcsrv.New(svc))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("iam 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,92 @@
package application
import (
"context"
"errors"
"time"
"enkly/pkg/authsupport"
"enkly/services/iam/internal/domain"
)
// sessionTTL matches the monolith's cookie Max-Age for the session
// cookie (30 days) — see handlers.Handlers.startSession.
const sessionTTL = 30 * 24 * time.Hour
// LoginResult is what the gRPC server maps onto LoginResponse.
type LoginResult struct {
OK bool
NeedsVerification bool
SessionToken string
SessionExpiresAt time.Time
User *domain.User
}
// Login replaces Handlers.Login: verifies credentials in constant time
// (an unknown email is checked against a dummy hash so the response
// takes the same time either way) and, on success, mints a session.
func (s *Service) Login(ctx context.Context, email, password, clientIP string) (LoginResult, error) {
ipKey, emailKey := "login:ip:"+clientIP, "login:email:"+email
if !throttle(ctx, s.LoginLimiter, ipKey, emailKey) {
return LoginResult{}, ctx.Err()
}
user, err := s.Users.GetByEmail(ctx, email)
if errors.Is(err, domain.ErrNotFound) {
authsupport.VerifyPassword(password, s.getDummyHash()) // constant-time decoy
return LoginResult{}, nil
}
if err != nil {
return LoginResult{}, err
}
if !authsupport.VerifyPassword(password, user.PasswordHash) {
return LoginResult{}, nil
}
if !user.EmailVerified {
return LoginResult{NeedsVerification: true}, nil
}
s.LoginLimiter.RecordSuccess(ipKey)
s.LoginLimiter.RecordSuccess(emailKey)
raw, hash, err := authsupport.NewToken()
if err != nil {
return LoginResult{}, err
}
expiresAt := time.Now().Add(sessionTTL)
if err := s.Sessions.Create(ctx, user.ID, hash, expiresAt); err != nil {
return LoginResult{}, err
}
return LoginResult{OK: true, SessionToken: raw, SessionExpiresAt: expiresAt, User: user}, nil
}
// Logout replaces Handlers.Logout.
func (s *Service) Logout(ctx context.Context, sessionToken string) error {
if sessionToken == "" {
return nil
}
return s.Sessions.Delete(ctx, authsupport.HashToken(sessionToken))
}
// ValidateSession replaces the session half of Handlers.RequireAuth
// (db.GetSessionUser). Called by the gateway on every authenticated
// request, so this is the one IAM RPC on the hot path of practically
// every request in the system — see ARCHITECTURE.md's "Session
// validation latency" note for the caching strategy a production
// rollout should add here.
func (s *Service) ValidateSession(ctx context.Context, sessionToken string) (*domain.User, bool) {
if sessionToken == "" {
return nil, false
}
u, err := s.Sessions.GetUserByTokenHash(ctx, authsupport.HashToken(sessionToken))
if err != nil {
return nil, false
}
return u, true
}
// GetUser replaces a direct db.DB lookup other services occasionally
// needed (e.g. billing resolving an email from a user_id for Stripe).
func (s *Service) GetUser(ctx context.Context, userID int64) (*domain.User, error) {
return s.Users.GetByID(ctx, userID)
}

View File

@@ -0,0 +1,69 @@
package application
import (
"context"
"errors"
"net/url"
"strings"
"time"
"enkly/pkg/authsupport"
"enkly/pkg/eventbus"
"enkly/pkg/eventbus/events"
"enkly/services/iam/internal/domain"
)
// absURL builds BaseURL + path + ?key=value, replacing
// Handlers.absURL.
func (s *Service) absURL(path, key, value string) string {
return strings.TrimRight(s.BaseURL, "/") + path + "?" + url.Values{key: {value}}.Encode()
}
// ForgotPassword replaces Handlers.ForgotPassword. Always succeeds from
// the caller's point of view — whether or not the address is registered
// is never revealed.
func (s *Service) ForgotPassword(ctx context.Context, email, clientIP string) error {
if !throttle(ctx, s.ResendLimiter, "forgot:ip:"+clientIP, "forgot:email:"+email) {
return ctx.Err()
}
u, err := s.Users.GetByEmail(ctx, email)
if err != nil {
return nil // unknown address — say nothing
}
raw, hash, err := authsupport.NewToken()
if err != nil {
return err
}
expiresAt := time.Now().Add(resetTokenTTL)
if err := s.PasswordReset.Create(ctx, u.ID, hash, expiresAt); err != nil {
return err
}
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyPasswordResetRequested, events.PasswordResetRequested{
UserID: u.ID, Email: u.Email, ResetLink: s.absURL("/reset-password", "token", raw), ExpiresAt: expiresAt,
})
return nil
}
// CheckPasswordResetToken replaces the GET-time validity check in
// Handlers.ResetPasswordPage.
func (s *Service) CheckPasswordResetToken(ctx context.Context, token string) (bool, error) {
return s.PasswordReset.Valid(ctx, authsupport.HashToken(token))
}
// ResetPassword replaces Handlers.ResetPassword.
func (s *Service) ResetPassword(ctx context.Context, token, password string) (bool, error) {
hash, err := authsupport.HashPassword(password)
if err != nil {
return false, err
}
_, err = s.PasswordReset.ConsumeAndResetPassword(ctx, authsupport.HashToken(token), hash)
switch {
case err == nil:
return true, nil
case errors.Is(err, domain.ErrInvalidToken):
return false, nil
default:
return false, err
}
}

View File

@@ -0,0 +1,194 @@
// Package application holds IAM's use cases — the direct transplant of
// internal/handlers/auth.go's business logic with every net/http,
// html/template and cookie concern stripped out (those stayed behind in
// the gateway service; see services/gateway). Each exported method here
// corresponds 1:1 to an rpc in api/proto/iam/v1/iam.proto and to the
// handler function named in its doc comment.
package application
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"enkly/pkg/authsupport"
"enkly/pkg/eventbus"
"enkly/pkg/eventbus/events"
"enkly/services/iam/internal/domain"
)
const (
verifyCodeTTL = 15 * time.Minute
resetTokenTTL = time.Hour
)
// Publisher is the subset of *eventbus.Bus the application layer needs —
// defined here (rather than importing eventbus.Bus directly into every
// signature) so a unit test can supply a fake.
type Publisher interface {
PublishBestEffort(ctx context.Context, routingKey string, payload any)
}
// Service implements every IAM use case. Constructed once in
// cmd/server/main.go and handed to the gRPC server
// (internal/interfaces/grpc).
type Service struct {
Users domain.UserRepository
Sessions domain.SessionRepository
Verification domain.VerificationRepository
PasswordReset domain.PasswordResetRepository
Events Publisher
Logger *slog.Logger
BaseURL string // for building the password-reset link, same as Handlers.BaseURL
// MailConfigured mirrors mail.Mailer.Configured() purely so SignUp/
// ResendVerification can report the same "written to the server log
// instead" hint the monolith showed — IAM never sends mail itself
// (see services/notification), it just knows whether the deployment
// has SMTP creds set, from the same ENKLY_SMTP_* env vars.
MailConfigured bool
LoginLimiter *authsupport.Limiter
ResendLimiter *authsupport.Limiter
VerifyLimiter *authsupport.Limiter
dummyHashOnce sync.Once
dummyHash string
}
func (s *Service) getDummyHash() string {
s.dummyHashOnce.Do(func() { s.dummyHash, _ = authsupport.HashPassword("enkly-dummy-password") })
return s.dummyHash
}
// SignUpResult is what the gRPC server maps onto SignUpResponse.
type SignUpResult struct {
CheckInbox bool
Email string
MailConfigured bool
}
// SignUp replaces Handlers.Signup. Email normalization and password
// confirmation are assumed already validated by the caller (the gateway
// does this at the edge, same division as the monolith's normalizeEmail/
// validatePassword helpers) — email arrives already normalized.
func (s *Service) SignUp(ctx context.Context, email, password, clientIP string) (SignUpResult, error) {
// Signups share the resend limiter (keyed per IP): creating accounts
// triggers outbound email, the same abuse vector as resending it.
if !throttle(ctx, s.ResendLimiter, "signup:"+clientIP) {
return SignUpResult{}, ctx.Err()
}
hash, err := authsupport.HashPassword(password)
if err != nil {
return SignUpResult{}, err
}
user, err := s.Users.Create(ctx, email, hash)
switch {
case err == nil:
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyUserRegistered, events.UserRegistered{
UserID: user.ID, Email: user.Email, CreatedAt: user.CreatedAt,
})
case errors.Is(err, domain.ErrEmailTaken):
// Don't reveal that the address is registered: fall through to
// the same "check your inbox" outcome, quietly resending a
// code if the existing account was never verified.
existing, gerr := s.Users.GetByEmail(ctx, email)
if gerr != nil || existing.EmailVerified {
return SignUpResult{CheckInbox: true, Email: email, MailConfigured: s.MailConfigured}, nil
}
user = existing
default:
return SignUpResult{}, err
}
if err := s.issueVerificationCode(ctx, user); err != nil {
return SignUpResult{}, err
}
return SignUpResult{CheckInbox: true, Email: email, MailConfigured: s.MailConfigured}, nil
}
// issueVerificationCode replaces sendVerificationEmail: mints a code,
// stores its hash, and publishes an event for the notification service
// to deliver it — decoupled from this call's own success/failure
// (see pkg/eventbus.Bus.PublishBestEffort's doc comment on why this
// must never block or fail the signup/resend response).
func (s *Service) issueVerificationCode(ctx context.Context, u *domain.User) error {
raw, hash, err := authsupport.NewCode()
if err != nil {
return err
}
expiresAt := time.Now().Add(verifyCodeTTL)
if err := s.Verification.Create(ctx, u.ID, hash, expiresAt); err != nil {
return err
}
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyVerificationCodeIssued, events.VerificationCodeIssued{
UserID: u.ID, Email: u.Email, Code: raw, ExpiresAt: expiresAt,
})
return nil
}
// ResendVerification replaces Handlers.ResendVerification.
func (s *Service) ResendVerification(ctx context.Context, email, clientIP string) error {
// Limit by both address and IP — one address can't be spammed, and
// one client can't spray many addresses.
if !throttle(ctx, s.ResendLimiter, "resend:ip:"+clientIP, "resend:email:"+email) {
return ctx.Err()
}
u, err := s.Users.GetByEmail(ctx, email)
if err == nil && !u.EmailVerified {
if err := s.issueVerificationCode(ctx, u); err != nil {
s.Logger.Error("resend verification", "err", err, "user_id", u.ID)
}
}
// Same answer whether or not the account exists / needs verifying —
// errors swallowed deliberately, matching the monolith.
return nil
}
// VerifyEmail replaces Handlers.VerifyEmail.
func (s *Service) VerifyEmail(ctx context.Context, email, code, clientIP string) (bool, error) {
ipKey, emailKey := "verify:ip:"+clientIP, "verify:email:"+email
// Progressive delay by IP and by email — a distinct limiter from
// login/resend, since guessing a 6-digit code is a distinct abuse
// vector from password-guessing or triggering outbound mail.
var wait time.Duration
for _, k := range []string{ipKey, emailKey} {
if dl := s.VerifyLimiter.Delay(k); dl > wait {
wait = dl
}
}
if !sleepCtx(ctx, wait) {
return false, ctx.Err()
}
user, err := s.Users.GetByEmail(ctx, email)
if errors.Is(err, domain.ErrNotFound) {
s.VerifyLimiter.RecordFailure(ipKey)
s.VerifyLimiter.RecordFailure(emailKey)
return false, nil // don't reveal whether the address is registered
}
if err != nil {
return false, err
}
err = s.Verification.VerifyAndConsume(ctx, user.ID, authsupport.HashToken(code))
switch {
case err == nil:
s.VerifyLimiter.RecordSuccess(ipKey)
s.VerifyLimiter.RecordSuccess(emailKey)
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyUserEmailVerified, events.UserEmailVerified{
UserID: user.ID, Email: user.Email,
})
return true, nil
case errors.Is(err, domain.ErrInvalidToken):
s.VerifyLimiter.RecordFailure(ipKey)
s.VerifyLimiter.RecordFailure(emailKey)
return false, nil
default:
return false, err
}
}

View File

@@ -0,0 +1,48 @@
package application
import (
"context"
"time"
"enkly/pkg/authsupport"
)
// throttle replaces Handlers.throttle: sleeps for the largest
// progressive delay among keys (honoring ctx cancellation — a gRPC
// client that gives up shouldn't leave this goroutine sleeping
// uselessly), then records an attempt against every key regardless of
// the eventual outcome. Returns false if ctx was cancelled first, in
// which case the caller should abort without doing any work (the
// monolith's handler returned straight to net/http in that case; here
// the gRPC server maps it to context.Canceled).
func throttle(ctx context.Context, l *authsupport.Limiter, keys ...string) bool {
var wait time.Duration
for _, k := range keys {
if d := l.Delay(k); d > wait {
wait = d
}
}
if !sleepCtx(ctx, wait) {
return false
}
for _, k := range keys {
l.RecordFailure(k)
}
return true
}
// sleepCtx sleeps for d or until ctx is done, reporting whether the full
// sleep elapsed (replaces handlers.sleepCtx).
func sleepCtx(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return ctx.Err() == nil
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}

View File

@@ -0,0 +1,15 @@
package domain
import "errors"
// Sentinel errors the application layer compares against with
// errors.Is — the same three the monolith's internal/db package
// exposed (db.ErrNotFound, db.ErrEmailTaken, db.ErrInvalidToken), now
// owned by the domain layer instead of a database package, since
// "email already taken" and "code invalid or expired" are business
// rules, not SQL facts.
var (
ErrNotFound = errors.New("iam: not found")
ErrEmailTaken = errors.New("iam: email already registered")
ErrInvalidToken = errors.New("iam: invalid or expired token")
)

View File

@@ -0,0 +1,92 @@
// Package domain holds the IAM bounded context's entities and the
// repository interfaces its application layer depends on. Nothing here
// imports database/sql, net/http or gRPC — see ARCHITECTURE.md's note
// on dependency direction ("domain depends on nothing; everything else
// depends on domain").
package domain
import (
"context"
"time"
)
// User is an enkly account — identical in shape to the monolith's
// models.User, minus the json tags (this is an internal entity now, not
// a value serialized straight to a template).
type User struct {
ID int64
Email string
PasswordHash string
EmailVerified bool
CreatedAt time.Time
}
// Session is one logged-in browser, identified at rest by the SHA-256
// hash of its cookie token (see pkg/authsupport.HashToken) — the raw
// token exists only in the cookie itself and the gRPC response that
// minted it.
type Session struct {
UserID int64
TokenHash string
ExpiresAt time.Time
}
// VerificationCode is a 6-digit email-verification code, stored only as
// its hash plus an attempt counter (see internal/db's original comment
// on why a short code needs active guessing defense that a 256-bit
// token wouldn't).
type VerificationCode struct {
UserID int64
CodeHash string
Attempts int
ExpiresAt time.Time
UsedAt *time.Time
}
// PasswordResetToken is a "forgot password" link's token, stored only
// as its hash.
type PasswordResetToken struct {
UserID int64
TokenHash string
ExpiresAt time.Time
UsedAt *time.Time
}
// UserRepository persists and retrieves User rows. Implemented by
// internal/infrastructure/postgres.UserRepo.
type UserRepository interface {
Create(ctx context.Context, email, passwordHash string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
GetByID(ctx context.Context, id int64) (*User, error)
MarkEmailVerified(ctx context.Context, userID int64) error
UpdatePassword(ctx context.Context, userID int64, newPasswordHash string) error
}
// SessionRepository persists and retrieves Session rows.
type SessionRepository interface {
Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error
GetUserByTokenHash(ctx context.Context, tokenHash string) (*User, error)
Delete(ctx context.Context, tokenHash string) error
DeleteAllForUser(ctx context.Context, userID int64) error
}
// VerificationRepository persists and retrieves VerificationCode rows.
type VerificationRepository interface {
Create(ctx context.Context, userID int64, codeHash string, expiresAt time.Time) error
// VerifyAndConsume replaces db.VerifyEmailCode: checks the most
// recent unused, unexpired code for userID against codeHash,
// incrementing its attempt counter on a mismatch and marking it
// used (and the user verified) on a match. Returns domain.ErrInvalid
// when no code matches.
VerifyAndConsume(ctx context.Context, userID int64, codeHash string) error
}
// PasswordResetRepository persists and retrieves PasswordResetToken rows.
type PasswordResetRepository interface {
Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error
Valid(ctx context.Context, tokenHash string) (bool, error)
// ConsumeAndResetPassword replaces db.ResetPassword: validates the
// token, updates the password hash, marks the token used, and
// deletes every session for the user, all in one transaction.
ConsumeAndResetPassword(ctx context.Context, tokenHash, newPasswordHash string) (userID int64, err error)
}

View File

@@ -0,0 +1,128 @@
// Package postgres implements IAM's domain repository interfaces against
// the iam database (see services/iam/migrations/0001_init.sql). This is
// a one-for-one transplant of the monolith's internal/db/users.go, split
// across files by aggregate instead of living in one package shared
// with every other table in the old schema.
package postgres
import (
"context"
"database/sql"
"errors"
"time"
"enkly/pkg/pgkit"
"enkly/services/iam/internal/domain"
)
const userColumns = `id, email, password_hash, email_verified, created_at`
func scanUser(row interface{ Scan(...any) error }) (*domain.User, error) {
var u domain.User
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.EmailVerified, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
return &u, nil
}
// UserRepo implements domain.UserRepository.
type UserRepo struct {
DB *pgkit.DB
}
func NewUserRepo(db *pgkit.DB) *UserRepo { return &UserRepo{DB: db} }
// Create inserts a new, unverified account. email must already be
// normalized (trimmed + lower-cased) by the application layer;
// passwordHash is the output of authsupport.HashPassword.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash string) (*domain.User, error) {
u, err := scanUser(r.DB.QueryRowContext(ctx,
`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING `+userColumns,
email, passwordHash))
if err != nil {
if pgkit.IsUniqueViolation(err) {
return nil, domain.ErrEmailTaken
}
return nil, err
}
return u, nil
}
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
return scanUser(r.DB.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE email = $1`, email))
}
func (r *UserRepo) GetByID(ctx context.Context, id int64) (*domain.User, error) {
return scanUser(r.DB.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE id = $1`, id))
}
func (r *UserRepo) MarkEmailVerified(ctx context.Context, userID int64) error {
_, err := r.DB.ExecContext(ctx, `UPDATE users SET email_verified = true WHERE id = $1`, userID)
return err
}
func (r *UserRepo) UpdatePassword(ctx context.Context, userID int64, newPasswordHash string) error {
_, err := r.DB.ExecContext(ctx, `UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash)
return err
}
// ---------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------
// SessionRepo implements domain.SessionRepository.
type SessionRepo struct {
DB *pgkit.DB
}
func NewSessionRepo(db *pgkit.DB) *SessionRepo { return &SessionRepo{DB: db} }
func (r *SessionRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
_, err := r.DB.ExecContext(ctx,
`INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
userID, tokenHash, expiresAt)
return err
}
func (r *SessionRepo) GetUserByTokenHash(ctx context.Context, tokenHash string) (*domain.User, error) {
return scanUser(r.DB.QueryRowContext(ctx, `
SELECT u.id, u.email, u.password_hash, u.email_verified, u.created_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now()`, tokenHash))
}
func (r *SessionRepo) Delete(ctx context.Context, tokenHash string) error {
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = $1`, tokenHash)
return err
}
func (r *SessionRepo) DeleteAllForUser(ctx context.Context, userID int64) error {
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID)
return err
}
// CleanupExpired deletes expired sessions and used/expired verification
// and password-reset tokens, mirroring internal/db.DB.CleanupExpired —
// run periodically by cmd/server's cleanup loop (see main.go).
func CleanupExpired(ctx context.Context, db *pgkit.DB) (int64, error) {
var total int64
for _, q := range []string{
`DELETE FROM sessions WHERE expires_at < now()`,
`DELETE FROM email_verification_codes WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
`DELETE FROM password_reset_tokens WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
} {
res, err := db.ExecContext(ctx, q)
if err != nil {
return total, err
}
n, _ := res.RowsAffected()
total += n
}
return total, nil
}

View File

@@ -0,0 +1,178 @@
package postgres
import (
"context"
"crypto/subtle"
"database/sql"
"errors"
"time"
"enkly/pkg/pgkit"
"enkly/services/iam/internal/domain"
)
// maxVerificationAttempts caps how many wrong codes a single
// verification row accepts before it's dead even to the correct code —
// see domain.VerificationRepository's doc comment.
const maxVerificationAttempts = 8
// VerificationRepo implements domain.VerificationRepository.
type VerificationRepo struct {
DB *pgkit.DB
}
func NewVerificationRepo(db *pgkit.DB) *VerificationRepo { return &VerificationRepo{DB: db} }
func (r *VerificationRepo) Create(ctx context.Context, userID int64, codeHash string, expiresAt time.Time) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM email_verification_codes WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO email_verification_codes (user_id, code_hash, expires_at) VALUES ($1, $2, $3)`,
userID, codeHash, expiresAt); err != nil {
return err
}
return tx.Commit()
}
func (r *VerificationRepo) VerifyAndConsume(ctx context.Context, userID int64, codeHash string) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var verified bool
if err := tx.QueryRowContext(ctx, `SELECT email_verified FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&verified); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrNotFound
}
return err
}
if verified {
return nil // idempotent for an already-verified account
}
var (
rowID int64
storedHash string
attempts int
expiresAt time.Time
)
err = tx.QueryRowContext(ctx, `
SELECT id, code_hash, attempts, expires_at
FROM email_verification_codes
WHERE user_id = $1 AND used_at IS NULL
ORDER BY created_at DESC LIMIT 1
FOR UPDATE`, userID).Scan(&rowID, &storedHash, &attempts, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrInvalidToken
}
if err != nil {
return err
}
if attempts >= maxVerificationAttempts || time.Now().After(expiresAt) {
return domain.ErrInvalidToken
}
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(codeHash)) != 1 {
if _, err := tx.ExecContext(ctx,
`UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1`, rowID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return domain.ErrInvalidToken
}
if _, err := tx.ExecContext(ctx,
`UPDATE email_verification_codes SET used_at = now() WHERE id = $1`, rowID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET email_verified = true WHERE id = $1`, userID); err != nil {
return err
}
return tx.Commit()
}
// ---------------------------------------------------------------------
// Password reset tokens
// ---------------------------------------------------------------------
// PasswordResetRepo implements domain.PasswordResetRepository.
type PasswordResetRepo struct {
DB *pgkit.DB
}
func NewPasswordResetRepo(db *pgkit.DB) *PasswordResetRepo { return &PasswordResetRepo{DB: db} }
func (r *PasswordResetRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
userID, tokenHash, expiresAt); err != nil {
return err
}
return tx.Commit()
}
func (r *PasswordResetRepo) Valid(ctx context.Context, tokenHash string) (bool, error) {
var ok bool
err := r.DB.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1 FROM password_reset_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
)`, tokenHash).Scan(&ok)
return ok, err
}
func (r *PasswordResetRepo) ConsumeAndResetPassword(ctx context.Context, tokenHash, newPasswordHash string) (int64, error) {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var userID int64
err = tx.QueryRowContext(ctx, `
UPDATE password_reset_tokens SET used_at = now()
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING user_id`, tokenHash).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, domain.ErrInvalidToken
}
if err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return userID, nil
}

View File

@@ -0,0 +1,128 @@
// Package grpc adapts application.Service onto the generated
// iamv1.IAMServiceServer interface — the only layer in this service
// that imports google.golang.org/grpc or the generated gen/iam/v1
// package. Keeping that import confined to this one file is what lets
// internal/application stay transport-agnostic and unit-testable
// without spinning up a gRPC server.
package grpc
import (
"context"
"errors"
"google.golang.org/protobuf/types/known/timestamppb"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/grpcerr"
"enkly/services/iam/internal/application"
"enkly/services/iam/internal/domain"
)
type Server struct {
iamv1.UnimplementedIAMServiceServer
Svc *application.Service
}
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
func toProtoUser(u *domain.User) *iamv1.User {
if u == nil {
return nil
}
return &iamv1.User{
Id: u.ID,
Email: u.Email,
EmailVerified: u.EmailVerified,
CreatedAt: timestamppb.New(u.CreatedAt),
}
}
func (s *Server) SignUp(ctx context.Context, req *iamv1.SignUpRequest) (*iamv1.SignUpResponse, error) {
res, err := s.Svc.SignUp(ctx, req.GetEmail(), req.GetPassword(), req.GetClientIp())
if err != nil {
return nil, err
}
return &iamv1.SignUpResponse{
CheckInbox: res.CheckInbox,
Email: res.Email,
MailConfigured: res.MailConfigured,
}, nil
}
func (s *Server) ResendVerification(ctx context.Context, req *iamv1.ResendVerificationRequest) (*iamv1.ResendVerificationResponse, error) {
if err := s.Svc.ResendVerification(ctx, req.GetEmail(), req.GetClientIp()); err != nil {
return nil, err
}
return &iamv1.ResendVerificationResponse{MailConfigured: s.Svc.MailConfigured}, nil
}
func (s *Server) VerifyEmail(ctx context.Context, req *iamv1.VerifyEmailRequest) (*iamv1.VerifyEmailResponse, error) {
ok, err := s.Svc.VerifyEmail(ctx, req.GetEmail(), req.GetCode(), req.GetClientIp())
if err != nil {
return nil, err
}
return &iamv1.VerifyEmailResponse{Verified: ok}, nil
}
func (s *Server) Login(ctx context.Context, req *iamv1.LoginRequest) (*iamv1.LoginResponse, error) {
res, err := s.Svc.Login(ctx, req.GetEmail(), req.GetPassword(), req.GetClientIp())
if err != nil {
return nil, err
}
resp := &iamv1.LoginResponse{Ok: res.OK, NeedsVerification: res.NeedsVerification}
if res.OK {
resp.SessionToken = res.SessionToken
resp.SessionExpiresAt = timestamppb.New(res.SessionExpiresAt)
resp.User = toProtoUser(res.User)
}
return resp, nil
}
func (s *Server) Logout(ctx context.Context, req *iamv1.LogoutRequest) (*iamv1.LogoutResponse, error) {
if err := s.Svc.Logout(ctx, req.GetSessionToken()); err != nil {
return nil, err
}
return &iamv1.LogoutResponse{}, nil
}
func (s *Server) ValidateSession(ctx context.Context, req *iamv1.ValidateSessionRequest) (*iamv1.ValidateSessionResponse, error) {
u, ok := s.Svc.ValidateSession(ctx, req.GetSessionToken())
if !ok {
return &iamv1.ValidateSessionResponse{Valid: false}, nil
}
return &iamv1.ValidateSessionResponse{Valid: true, User: toProtoUser(u)}, nil
}
func (s *Server) ForgotPassword(ctx context.Context, req *iamv1.ForgotPasswordRequest) (*iamv1.ForgotPasswordResponse, error) {
if err := s.Svc.ForgotPassword(ctx, req.GetEmail(), req.GetClientIp()); err != nil {
return nil, err
}
return &iamv1.ForgotPasswordResponse{}, nil
}
func (s *Server) CheckPasswordResetToken(ctx context.Context, req *iamv1.CheckPasswordResetTokenRequest) (*iamv1.CheckPasswordResetTokenResponse, error) {
ok, err := s.Svc.CheckPasswordResetToken(ctx, req.GetToken())
if err != nil {
return nil, err
}
return &iamv1.CheckPasswordResetTokenResponse{Valid: ok}, nil
}
func (s *Server) ResetPassword(ctx context.Context, req *iamv1.ResetPasswordRequest) (*iamv1.ResetPasswordResponse, error) {
ok, err := s.Svc.ResetPassword(ctx, req.GetToken(), req.GetPassword())
if err != nil {
return nil, err
}
return &iamv1.ResetPasswordResponse{Ok: ok}, nil
}
func (s *Server) GetUser(ctx context.Context, req *iamv1.GetUserRequest) (*iamv1.User, error) {
u, err := s.Svc.GetUser(ctx, req.GetUserId())
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, grpcerr.NotFound("user")
}
return nil, err
}
return toProtoUser(u), nil
}

View File

@@ -0,0 +1,67 @@
-- =====================================================================
-- iam service — 0001_init.sql
-- Database-per-service: this is the ONLY schema that owns `users`,
-- `sessions`, `email_verification_codes` and `password_reset_tokens`.
-- No other service's migration may create a table with these names or
-- reach into this database. Transplanted verbatim from the monolith's
-- schema.sql section of the same name; see ARCHITECTURE.md's
-- "Database-per-service" note for why the FKs below stay *within* this
-- file only — every other service refers to a user by a bare
-- `user_id BIGINT`, not a foreign key, since Postgres can't enforce a
-- constraint across two separate databases.
-- =====================================================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Email verification codes: a short numeric code (not a link) mailed to
-- the address at signup. `attempts` caps how many wrong codes a row
-- will accept before it's dead (see internal/application.VerifyEmail);
-- code_hash is deliberately NOT unique — two different users' current
-- codes can coincidentally collide, which is expected and harmless
-- since every lookup is scoped by user_id, never by code_hash alone.
CREATE TABLE IF NOT EXISTS email_verification_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS email_verification_codes_user_id_idx
ON email_verification_codes (user_id);
CREATE TABLE IF NOT EXISTS sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions (user_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions (expires_at);
-- "Forgot password" tokens — same hash-only-at-rest pattern as
-- email_verification_codes. A successful reset also deletes every row
-- in `sessions` for that user, so a stolen session cookie doesn't
-- survive a reset (see application.PasswordResetRepo.
-- ConsumeAndResetPassword).
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens (user_id);

View File

@@ -0,0 +1,19 @@
# See services/iam/Dockerfile for the pattern explanation. This image's
# counterpart in docker-compose.yml uses pgvector/pgvector as its
# Postgres image (plain postgres lacks the vector extension).
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/knowledge/ services/knowledge/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/knowledge-server ./services/knowledge/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/knowledge-server ./knowledge-server
COPY --from=build /src/services/knowledge/migrations ./migrations
ENV ENKLY_KNOWLEDGE_GRPC_ADDR=:9005
EXPOSE 9005
ENTRYPOINT ["./knowledge-server"]

View File

@@ -0,0 +1,109 @@
// Command server runs the knowledge microservice: a gRPC server
// exposing api/proto/knowledge/v1, backed by its own pgvector-enabled
// Postgres database and a vLLM-served embedding model. Replaces
// internal/rag/*.go.
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"
knowledgev1 "enkly/gen/knowledge/v1"
"enkly/pkg/envkit"
"enkly/pkg/pgkit"
"enkly/pkg/vllmkit"
"enkly/services/knowledge/internal/application"
"enkly/services/knowledge/internal/infrastructure/postgres"
grpcsrv "enkly/services/knowledge/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_KNOWLEDGE_GRPC_ADDR", ":9005")
postgresDSN = envkit.String("ENKLY_KNOWLEDGE_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_knowledge?sslmode=disable")
migrationsPath = envkit.String("ENKLY_KNOWLEDGE_MIGRATIONS", "migrations/0001_init.sql")
// vllmEmbedURL points at the vLLM OpenAI-compatible deployment
// serving the embedding model — its own vLLM engine process,
// independent of (and typically scaled very differently from)
// the chat deployment the agent service talks to, since vLLM
// serves exactly one model per server. See k8s/11-vllm.yaml.
// This service never calls the chat model, so it only ever
// constructs a vllmkit.Client with chatBaseURL left empty.
vllmEmbedURL = envkit.String("ENKLY_VLLM_EMBED_URL", "http://localhost:8001/v1")
embedModel = envkit.String("ENKLY_EMBED_MODEL", vllmkit.DefaultEmbedModel)
// vllmAPIKey is sent as a bearer token on every embed request
// (vLLM's --api-key flag) — see k8s/02-secrets.yaml.
vllmAPIKey = envkit.String("ENKLY_VLLM_API_KEY", "")
)
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)
}
// chatBaseURL is deliberately left "" (defaults to vllmkit's own
// unused-but-harmless placeholder) — this service never calls
// ChatStream, only Embed, so only embedBaseURL matters here.
llmClient := vllmkit.New("", vllmEmbedURL, "", embedModel)
llmClient.APIKey = vllmAPIKey
svc := &application.Service{
Documents: postgres.NewDocumentRepo(db),
Embedder: llmClient,
}
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
knowledgev1.RegisterKnowledgeServiceServer(grpcServer, grpcsrv.New(svc))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("knowledge service listening", "addr", grpcAddr, "embed_model", embedModel)
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,95 @@
// Package application holds knowledge's use cases — the content of
// internal/rag/store.go's IndexFile/Search methods, now split from
// their SQL (internal/infrastructure/postgres) so this package depends
// only on domain.DocumentRepository/domain.Embedder.
package application
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"enkly/services/knowledge/internal/domain"
)
type Service struct {
Documents domain.DocumentRepository
Embedder domain.Embedder
}
func hashContent(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
// upsertChunk replaces rag.Store.UpsertChunk: embeds a single chunk and
// stores it, skipping the embedding call entirely if the chunk's
// content hasn't changed since it was last indexed.
func (s *Service) upsertChunk(ctx context.Context, filePath string, chunkIndex int, content string) error {
hash := hashContent(content)
existing, err := s.Documents.ExistingContentHash(ctx, filePath, chunkIndex)
if err != nil {
return err
}
if existing == hash {
return nil // unchanged, skip re-embedding
}
vec, err := s.Embedder.Embed(ctx, content)
if err != nil {
return fmt.Errorf("knowledge: embed %s chunk %d: %w", filePath, chunkIndex, err)
}
if len(vec) != domain.EmbeddingDim {
return fmt.Errorf("knowledge: embedding model returned %d-dimensional vectors but the schema declares vector(%d) — "+
"use a %d-dim model (the default nomic-embed-text is) or change the column and re-index",
len(vec), domain.EmbeddingDim, domain.EmbeddingDim)
}
if err := s.Documents.Upsert(ctx, filePath, chunkIndex, content, hash, vec); err != nil {
return fmt.Errorf("knowledge: upsert %s chunk %d: %w", filePath, chunkIndex, err)
}
return nil
}
// IndexFile replaces rag.Store.IndexFile.
func (s *Service) IndexFile(ctx context.Context, filePath, content string) (indexed, skipped int, err error) {
chunks := domain.ChunkText(content)
if err := s.Documents.DeleteChunksFrom(ctx, filePath, len(chunks)); err != nil {
return 0, 0, fmt.Errorf("knowledge: prune stale chunks for %s: %w", filePath, err)
}
for i, chunk := range chunks {
before, _ := s.Documents.ExistingContentHash(ctx, filePath, i)
if err := s.upsertChunk(ctx, filePath, i, chunk); err != nil {
return indexed, skipped, err
}
if before == hashContent(chunk) {
skipped++
} else {
indexed++
}
}
return indexed, skipped, nil
}
// PruneMissing replaces rag.Store.pruneMissing.
func (s *Service) PruneMissing(ctx context.Context, keepFilePaths []string) (int, error) {
return s.Documents.PruneMissing(ctx, keepFilePaths)
}
// Search replaces rag.Store.Search.
func (s *Service) Search(ctx context.Context, query string, topK int) ([]domain.CodeChunk, error) {
if topK <= 0 {
topK = 6
}
vec, err := s.Embedder.Embed(ctx, query)
if err != nil {
return nil, fmt.Errorf("knowledge: embed query: %w", err)
}
return s.Documents.Search(ctx, vec, topK)
}
// ListIndexedFiles replaces db.ListIndexedFiles.
func (s *Service) ListIndexedFiles(ctx context.Context, limit int) ([]string, error) {
return s.Documents.ListIndexedFiles(ctx, limit)
}

View File

@@ -0,0 +1,120 @@
package domain
import (
"strings"
"unicode/utf8"
)
const (
// chunkMaxChars is the target upper bound for one chunk. ~1,800 chars
// is roughly 450–600 tokens of code: small enough that a similarity
// hit points at a specific function rather than a whole file, large
// enough that a function usually fits in one chunk. It is well under
// nomic-embed-text's 2,048-token context, so nothing is truncated.
chunkMaxChars = 1800
// chunkOverlapChars of trailing context (whole lines) is repeated at
// the start of the next chunk, so a match that straddles a boundary
// still lands in at least one chunk with its surroundings.
chunkOverlapChars = 240
)
// ChunkText splits source text into overlapping, line-aligned chunks of
// at most ~chunkMaxChars characters. Whitespace-only input yields nil.
//
// Chunks break on line boundaries (never mid-line, except for a single
// line longer than the limit, e.g. minified JS, which is split at rune
// boundaries) so each chunk reads as valid-looking code.
func ChunkText(content string) []string {
if strings.TrimSpace(content) == "" {
return nil
}
content = strings.ReplaceAll(content, "\r\n", "\n")
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
var (
chunks []string
cur []string
curSize int
)
flush := func() {
text := strings.Join(cur, "\n")
if strings.TrimSpace(text) != "" {
chunks = append(chunks, text)
}
}
// startNext seeds the next chunk with up to chunkOverlapChars of the
// previous chunk's trailing lines.
startNext := func() {
var keep []string
size := 0
for i := len(cur) - 1; i >= 0; i-- {
l := len(cur[i]) + 1
if size+l > chunkOverlapChars {
break
}
keep = append([]string{cur[i]}, keep...)
size += l
}
cur, curSize = keep, size
}
for _, line := range lines {
for _, piece := range splitLongLine(line, chunkMaxChars) {
add := len(piece) + 1
if curSize+add > chunkMaxChars && len(cur) > 0 {
flush()
startNext()
// If the overlap plus this piece still overflows, drop the overlap:
// guarantees forward progress for pathological inputs.
if curSize+add > chunkMaxChars {
cur, curSize = nil, 0
}
}
cur = append(cur, piece)
curSize += add
}
}
if len(cur) > 0 {
flush()
}
return dedupeTail(chunks)
}
// splitLongLine breaks a single line longer than max bytes into pieces of
// at most max bytes, cutting only at rune boundaries.
func splitLongLine(line string, max int) []string {
if len(line) <= max {
return []string{line}
}
var out []string
for len(line) > max {
cut := max
for cut > 0 && !utf8.RuneStart(line[cut]) {
cut--
}
if cut == 0 { // no rune boundary found (invalid UTF-8): hard cut
cut = max
}
out = append(out, line[:cut])
line = line[cut:]
}
if line != "" {
out = append(out, line)
}
return out
}
// dedupeTail drops a final chunk that is nothing but overlap already
// contained in the previous chunk (which happens when the last flush
// carried only re-seeded lines).
func dedupeTail(chunks []string) []string {
n := len(chunks)
if n >= 2 && strings.HasSuffix(chunks[n-2], chunks[n-1]) {
return chunks[:n-1]
}
return chunks
}

View File

@@ -0,0 +1,45 @@
package domain
import "context"
// EmbeddingDim is the vector width migrations/0001_init.sql declares
// for documents.embedding (vector(768), matching nomic-embed-text).
const EmbeddingDim = 768
// CodeChunk is a single embedded chunk of a source file, as returned by
// a similarity search — identical in shape to the monolith's
// models.CodeChunk.
type CodeChunk struct {
ID int64
FilePath string
ChunkIndex int
Content string
Distance float64 // cosine distance; lower = more relevant
}
// Embedder is the subset of *vllmkit.Client the application layer
// needs — defined here (rather than depending on the concrete client
// type) purely to keep the dependency explicit and mockable in tests,
// exactly like the monolith's rag.Embedder.
type Embedder interface {
Embed(ctx context.Context, text string) ([]float32, error)
}
// DocumentRepository persists chunked, embedded documents — the
// pgvector-backed transplant of internal/rag/store.go +
// internal/rag/index.go's pruneMissing.
type DocumentRepository interface {
// ExistingContentHash returns the content_hash stored for
// (filePath, chunkIndex), or "" if no such row exists — used to
// skip re-embedding an unchanged chunk (see UpsertChunk's doc
// comment in the monolith).
ExistingContentHash(ctx context.Context, filePath string, chunkIndex int) (string, error)
Upsert(ctx context.Context, filePath string, chunkIndex int, content, contentHash string, embedding []float32) error
// DeleteChunksFrom removes any chunk_index >= fromIndex for
// filePath — stale trailing chunks left over from a previous,
// longer version of the file.
DeleteChunksFrom(ctx context.Context, filePath string, fromIndex int) error
Search(ctx context.Context, embedding []float32, topK int) ([]CodeChunk, error)
PruneMissing(ctx context.Context, keepFilePaths []string) (int, error)
ListIndexedFiles(ctx context.Context, limit int) ([]string, error)
}

View File

@@ -0,0 +1,124 @@
// Package postgres implements knowledge's DocumentRepository against
// the pgvector-backed knowledge database. Transplant of
// internal/rag/store.go's SQL (vectorLiteral and all) plus
// internal/rag/index.go's pruneMissing, and internal/db.DB.
// ListIndexedFiles.
package postgres
import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"github.com/lib/pq"
"enkly/pkg/pgkit"
"enkly/services/knowledge/internal/domain"
)
// vectorLiteral formats a float32 embedding as the string pgvector
// expects for input, e.g. "[0.011,-0.42,...]" — lib/pq has no native
// vector type, so it's sent as text and cast with ::vector in SQL.
func vectorLiteral(v []float32) string {
parts := make([]string, len(v))
for i, f := range v {
parts[i] = strconv.FormatFloat(float64(f), 'f', 8, 32)
}
return "[" + strings.Join(parts, ",") + "]"
}
type DocumentRepo struct {
DB *pgkit.DB
}
func NewDocumentRepo(db *pgkit.DB) *DocumentRepo { return &DocumentRepo{DB: db} }
func (r *DocumentRepo) ExistingContentHash(ctx context.Context, filePath string, chunkIndex int) (string, error) {
var hash string
err := r.DB.QueryRowContext(ctx,
`SELECT content_hash FROM documents WHERE file_path = $1 AND chunk_index = $2`,
filePath, chunkIndex).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return hash, err
}
func (r *DocumentRepo) Upsert(ctx context.Context, filePath string, chunkIndex int, content, contentHash string, embedding []float32) error {
_, err := r.DB.ExecContext(ctx, `
INSERT INTO documents (file_path, chunk_index, content, content_hash, embedding, updated_at)
VALUES ($1, $2, $3, $4, $5::vector, now())
ON CONFLICT (file_path, chunk_index)
DO UPDATE SET content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash,
embedding = EXCLUDED.embedding,
updated_at = now()`,
filePath, chunkIndex, content, contentHash, vectorLiteral(embedding))
return err
}
func (r *DocumentRepo) DeleteChunksFrom(ctx context.Context, filePath string, fromIndex int) error {
_, err := r.DB.ExecContext(ctx,
`DELETE FROM documents WHERE file_path = $1 AND chunk_index >= $2`, filePath, fromIndex)
return err
}
func (r *DocumentRepo) Search(ctx context.Context, embedding []float32, topK int) ([]domain.CodeChunk, error) {
lit := vectorLiteral(embedding)
rows, err := r.DB.QueryContext(ctx, `
SELECT id, file_path, chunk_index, content, embedding <=> $1::vector AS distance
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT $2`, lit, topK)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.CodeChunk
for rows.Next() {
var c domain.CodeChunk
if err := rows.Scan(&c.ID, &c.FilePath, &c.ChunkIndex, &c.Content, &c.Distance); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *DocumentRepo) PruneMissing(ctx context.Context, keep []string) (int, error) {
if keep == nil {
keep = []string{}
}
res, err := r.DB.ExecContext(ctx,
`DELETE FROM documents WHERE NOT (file_path = ANY($1))`, pq.Array(keep))
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
return int(n), err
}
func (r *DocumentRepo) ListIndexedFiles(ctx context.Context, limit int) ([]string, error) {
if limit <= 0 {
limit = 200
}
rows, err := r.DB.QueryContext(ctx,
`SELECT DISTINCT file_path FROM documents ORDER BY file_path LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,73 @@
package grpc
import (
"context"
knowledgev1 "enkly/gen/knowledge/v1"
"enkly/pkg/grpcerr"
"enkly/pkg/vllmkit"
"enkly/services/knowledge/internal/application"
"enkly/services/knowledge/internal/domain"
)
type Server struct {
knowledgev1.UnimplementedKnowledgeServiceServer
Svc *application.Service
}
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
// fromErr maps an application-layer error to the right gRPC status: an
// unreachable vllm-embed deployment (Pending, cold-starting,
// mid-rollout — see vllmkit.ErrUpstreamUnavailable) is
// codes.Unavailable, safe for a well-behaved caller to retry, not the
// opaque codes.Internal every other failure gets.
func fromErr(err error) error {
if vllmkit.IsUpstreamUnavailable(err) {
return grpcerr.Unavailable(err)
}
return grpcerr.Internal(err)
}
func toProtoChunk(c domain.CodeChunk) *knowledgev1.CodeChunk {
return &knowledgev1.CodeChunk{
Id: c.ID, FilePath: c.FilePath, ChunkIndex: int32(c.ChunkIndex),
Content: c.Content, Distance: c.Distance,
}
}
func (s *Server) IndexFile(ctx context.Context, req *knowledgev1.IndexFileRequest) (*knowledgev1.IndexFileResponse, error) {
indexed, skipped, err := s.Svc.IndexFile(ctx, req.GetFilePath(), req.GetContent())
if err != nil {
return nil, fromErr(err)
}
return &knowledgev1.IndexFileResponse{ChunksIndexed: int32(indexed), ChunksSkipped: int32(skipped)}, nil
}
func (s *Server) PruneMissing(ctx context.Context, req *knowledgev1.PruneMissingRequest) (*knowledgev1.PruneMissingResponse, error) {
n, err := s.Svc.PruneMissing(ctx, req.GetKeepFilePaths())
if err != nil {
return nil, grpcerr.Internal(err)
}
return &knowledgev1.PruneMissingResponse{RowsDeleted: int32(n)}, nil
}
func (s *Server) Search(ctx context.Context, req *knowledgev1.SearchRequest) (*knowledgev1.SearchResponse, error) {
chunks, err := s.Svc.Search(ctx, req.GetQuery(), int(req.GetTopK()))
if err != nil {
return nil, fromErr(err)
}
resp := &knowledgev1.SearchResponse{}
for _, c := range chunks {
resp.Chunks = append(resp.Chunks, toProtoChunk(c))
}
return resp, nil
}
func (s *Server) ListIndexedFiles(ctx context.Context, req *knowledgev1.ListIndexedFilesRequest) (*knowledgev1.ListIndexedFilesResponse, error) {
files, err := s.Svc.ListIndexedFiles(ctx, int(req.GetLimit()))
if err != nil {
return nil, grpcerr.Internal(err)
}
return &knowledgev1.ListIndexedFilesResponse{FilePaths: files}, nil
}

View File

@@ -0,0 +1,27 @@
-- =====================================================================
-- knowledge service — 0001_init.sql
-- Database-per-service: owns `documents`, the pgvector-backed RAG
-- index. Requires PostgreSQL 15+ with the pgvector extension available
-- (see deploy — the knowledge database uses the pgvector/pgvector
-- Docker image rather than plain postgres).
-- =====================================================================
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS documents (
id BIGSERIAL PRIMARY KEY,
file_path TEXT NOT NULL,
chunk_index INT NOT NULL DEFAULT 0,
content TEXT NOT NULL,
content_hash TEXT NOT NULL, -- sha256 of `content`, used to skip re-embedding unchanged chunks
embedding vector(768) NOT NULL, -- 768 = nomic-embed-text's output size; see domain.EmbeddingDim
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (file_path, chunk_index)
);
CREATE INDEX IF NOT EXISTS documents_embedding_hnsw_idx
ON documents USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS documents_file_path_idx
ON documents (file_path);

View File

@@ -0,0 +1,17 @@
# See services/iam/Dockerfile for the pattern explanation. This service
# has no gRPC API — only a RabbitMQ consumer and a tiny health endpoint.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/notification/ services/notification/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/notification-server ./services/notification/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/notification-server ./notification-server
ENV ENKLY_NOTIFICATION_HEALTH_ADDR=:9007
EXPOSE 9007
ENTRYPOINT ["./notification-server"]

View File

@@ -0,0 +1,81 @@
// Command server runs the notification microservice: a pure RabbitMQ
// consumer with no gRPC server and no database of its own — it sends
// email in reaction to events published by iam. Replaces the email-
// sending half of internal/handlers/auth.go + internal/mail/mail.go.
package main
import (
"context"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"enkly/pkg/envkit"
"enkly/pkg/eventbus"
"enkly/pkg/mailkit"
"enkly/services/notification/internal/application"
)
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 (
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
healthAddr = envkit.String("ENKLY_NOTIFICATION_HEALTH_ADDR", ":9007")
smtpHost = envkit.String("ENKLY_SMTP_HOST", "smtp.gmail.com")
smtpPort = envkit.String("ENKLY_SMTP_PORT", "587")
smtpUsername = envkit.String("ENKLY_SMTP_USERNAME", "")
smtpPassword = envkit.String("ENKLY_SMTP_PASSWORD", "")
smtpFrom = envkit.String("ENKLY_SMTP_FROM", "")
)
mailer := mailkit.New(mailkit.Config{
Host: smtpHost, Port: smtpPort, Username: smtpUsername, Password: smtpPassword, From: smtpFrom,
})
if !mailer.Configured() {
logger.Warn("mail is not configured — verification codes and reset links will be logged, not emailed",
"hint", "set ENKLY_SMTP_USERNAME and ENKLY_SMTP_PASSWORD (a Gmail App Password) in .env")
}
bus, err := eventbus.Connect(rabbitURL, logger)
if err != nil {
logger.Error("connect rabbitmq", "err", err)
os.Exit(1)
}
defer bus.Close()
svc := &application.Service{Mailer: mailer, Logger: logger}
// A minimal HTTP health endpoint — this service has no gRPC API
// (nothing calls it synchronously), but docker-compose and an
// orchestrator still want something to poll.
go func() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
lis, err := net.Listen("tcp", healthAddr)
if err != nil {
logger.Error("health listen", "err", err)
return
}
_ = http.Serve(lis, mux)
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
logger.Info("notification service consuming", "queue", eventbus.NotificationQueueBindings.Queue, "mail_configured", mailer.Configured())
if err := bus.Consume(ctx, eventbus.NotificationQueueBindings, svc.HandleEvent); err != nil && ctx.Err() == nil {
logger.Error("consume", "err", err)
os.Exit(1)
}
logger.Info("shutting down")
}

View File

@@ -0,0 +1,96 @@
// Package application is the notification service's entire job: listen
// for the events iam publishes when it needs an email sent, and send
// it. Replaces the parts of internal/handlers/auth.go that called
// mail.Mailer.Send inline (sendVerificationEmail, sendResetEmail),
// decoupled onto RabbitMQ per the task's requirement that asynchronous,
// no-reply-needed work use the message bus rather than a synchronous
// call — sending an email has no result the caller
// (iam's SignUp/ResendVerification/ForgotPassword RPCs) needs to wait
// for.
package application
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"enkly/pkg/eventbus"
"enkly/pkg/eventbus/events"
"enkly/pkg/mailkit"
)
type Service struct {
Mailer *mailkit.Mailer
Logger *slog.Logger
}
// HandleEvent is the single eventbus.Handler this service registers —
// see cmd/server/main.go's Consume call. It switches on the envelope's
// routing key the same way the monolith's handlers switched on which
// function (sendVerificationEmail vs. sendResetEmail) was calling
// mail.Send.
func (s *Service) HandleEvent(ctx context.Context, env eventbus.Envelope) error {
switch env.RoutingKey {
case eventbus.RoutingKeyVerificationCodeIssued:
var ev events.VerificationCodeIssued
if err := decode(env, &ev); err != nil {
return err
}
return s.sendVerificationEmail(ctx, ev)
case eventbus.RoutingKeyPasswordResetRequested:
var ev events.PasswordResetRequested
if err := decode(env, &ev); err != nil {
return err
}
return s.sendResetEmail(ctx, ev)
default:
// Bound to this queue by mistake (topology drift) — log and ack
// rather than requeue-looping forever on a message we'll never
// know how to handle.
s.Logger.Warn("notification: unhandled routing key", "routing_key", env.RoutingKey)
return nil
}
}
func decode(env eventbus.Envelope, v any) error {
if err := json.Unmarshal(env.Payload, v); err != nil {
return fmt.Errorf("decode %s payload: %w", env.RoutingKey, err)
}
return nil
}
// sendVerificationEmail replaces Handlers.sendVerificationEmail.
func (s *Service) sendVerificationEmail(_ context.Context, ev events.VerificationCodeIssued) error {
subject := "Verify your enkly account"
body := fmt.Sprintf(
"Hi,\n\nYour enkly verification code is: %s\n\nThis code expires in 15 minutes. If you didn't request this, you can ignore this email.\n",
ev.Code)
if !s.Mailer.Configured() {
s.Logger.Info("mail not configured — verification code logged instead", "email", ev.Email, "code", ev.Code)
return nil
}
if err := s.Mailer.Send(ev.Email, subject, body); err != nil {
return fmt.Errorf("send verification email to %s: %w", ev.Email, err)
}
return nil
}
// sendResetEmail replaces Handlers.sendResetEmail.
func (s *Service) sendResetEmail(_ context.Context, ev events.PasswordResetRequested) error {
subject := "Reset your enkly password"
body := fmt.Sprintf(
"Hi,\n\nSomeone (hopefully you) requested a password reset for your enkly account.\n\n"+
"Reset it here: %s\n\nThis link expires in an hour. If you didn't request this, you can ignore this email.\n",
ev.ResetLink)
if !s.Mailer.Configured() {
s.Logger.Info("mail not configured — reset link logged instead", "email", ev.Email, "link", ev.ResetLink)
return nil
}
if err := s.Mailer.Send(ev.Email, subject, body); err != nil {
return fmt.Errorf("send reset email to %s: %w", ev.Email, err)
}
return nil
}

View File

@@ -0,0 +1,23 @@
# See services/iam/Dockerfile for the pattern explanation. Workspace
# additionally needs common CLI tools available inside the container
# for run_bash_command (git, go, node, python...) — extend this image's
# second stage as your allowlist (ENKLY_BASH_ALLOWLIST) requires; kept
# minimal here since the allowlist itself is operator-configured.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/workspace/ services/workspace/
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/workspace-server ./services/workspace/cmd/server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/workspace-server ./workspace-server
ENV ENKLY_WORKSPACE_GRPC_ADDR=:9004
ENV ENKLY_WORKSPACE=/data/workspace
VOLUME ["/data/workspace"]
EXPOSE 9004
ENTRYPOINT ["./workspace-server"]

View File

@@ -0,0 +1,96 @@
// Command server runs the workspace microservice: a gRPC server
// exposing api/proto/workspace/v1 over the ENKLY_WORKSPACE directory on
// its own volume. Replaces internal/llm/tools.go + sandbox.go (as
// agent tool executors) and internal/handlers/files.go (as the human
// upload/download/tree backend).
package main
import (
"context"
"log/slog"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/envkit"
"enkly/services/workspace/internal/infrastructure/sandboxfs"
grpcsrv "enkly/services/workspace/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_WORKSPACE_GRPC_ADDR", ":9004")
workspaceRoot = envkit.String("ENKLY_WORKSPACE", "./workspace")
bashTimeoutSeconds = envkit.Int("ENKLY_BASH_TIMEOUT_SECONDS", 0)
bashAllowlistRaw = envkit.String("ENKLY_BASH_ALLOWLIST", "")
)
toolbox, err := sandboxfs.NewToolbox(workspaceRoot)
if err != nil {
logger.Error("init toolbox", "err", err)
os.Exit(1)
}
if bashTimeoutSeconds > 0 {
toolbox.CommandTimeout = time.Duration(bashTimeoutSeconds) * time.Second
}
// ENKLY_BASH_ALLOWLIST: comma-separated base binaries (first token
// only) run_bash_command may execute. Subcommand rules for go/git
// live in code (sandbox.go), not in this list — unchanged from the
// monolith.
if strings.TrimSpace(bashAllowlistRaw) != "" {
list, err := sandboxfs.ParseAllowlist(bashAllowlistRaw)
if err != nil {
logger.Error("invalid ENKLY_BASH_ALLOWLIST", "err", err)
os.Exit(1)
}
toolbox.AllowedCommands = list
}
logger.Info("workspace ready", "root", toolbox.WorkspaceRoot, "bash_timeout", toolbox.CommandTimeout)
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
// MaxRecvMsgSize/MaxSendMsgSize raised above gRPC's 4 MiB default:
// UploadFile/DownloadFile/WriteFile can legitimately carry a
// multi-megabyte file (see maxUploadFileBytes in the gateway's
// files handler, which is also 20 MiB).
grpcServer := grpc.NewServer(grpc.MaxRecvMsgSize(24<<20), grpc.MaxSendMsgSize(24<<20))
workspacev1.RegisterWorkspaceServiceServer(grpcServer, grpcsrv.New(toolbox))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("workspace 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,78 @@
package sandboxfs
import "context"
// The methods below are thin, exported wrappers around the Toolbox's
// unexported per-tool methods (readFile, writeFile, ...), built so the
// gRPC layer (internal/interfaces/grpc) can call them without needing
// package-private access. Each one keeps the original tool's exact
// map[string]any argument contract internally — this is deliberate:
// agent-service tool-calling, the gRPC layer here, and the underlying
// tool implementation all agree on the same argument names
// ("old_string", "replace_all", "path_glob", ...), so nothing about the
// monolith's tool behavior (including its exact success/error message
// text, which the model has been prompted around) has to change.
func (t *Toolbox) ReadFile(path string, offset, limit int) ToolResult {
args := map[string]any{"path": path}
if offset != 0 {
args["offset"] = float64(offset)
}
if limit != 0 {
args["limit"] = float64(limit)
}
return t.readFile(ToolCallRequest{Name: "read_file", Arguments: args})
}
func (t *Toolbox) WriteFile(path, content string) ToolResult {
return t.writeFile(ToolCallRequest{Name: "write_file", Arguments: map[string]any{
"path": path, "content": content,
}})
}
func (t *Toolbox) EditFile(path, oldString, newString string, replaceAll bool) ToolResult {
return t.editFile(ToolCallRequest{Name: "edit_file", Arguments: map[string]any{
"path": path, "old_string": oldString, "new_string": newString, "replace_all": replaceAll,
}})
}
// WorkspaceEdit mirrors one element of multi_edit_file's "edits" array.
type WorkspaceEdit struct {
OldString string
NewString string
ReplaceAll bool
}
func (t *Toolbox) MultiEditFile(path string, edits []WorkspaceEdit) ToolResult {
rawEdits := make([]any, len(edits))
for i, e := range edits {
rawEdits[i] = map[string]any{
"old_string": e.OldString, "new_string": e.NewString, "replace_all": e.ReplaceAll,
}
}
return t.multiEditFile(ToolCallRequest{Name: "multi_edit_file", Arguments: map[string]any{
"path": path, "edits": rawEdits,
}})
}
func (t *Toolbox) ListDirectory(path string) ToolResult {
return t.listDirectory(ToolCallRequest{Name: "list_directory", Arguments: map[string]any{"path": path}})
}
func (t *Toolbox) GlobSearch(pattern, path string) ToolResult {
return t.globSearch(ToolCallRequest{Name: "glob_search", Arguments: map[string]any{
"pattern": pattern, "path": path,
}})
}
func (t *Toolbox) Grep(pattern, path, pathGlob string, caseInsensitive bool) ToolResult {
return t.grep(ToolCallRequest{Name: "grep", Arguments: map[string]any{
"pattern": pattern, "path": path, "path_glob": pathGlob, "case_insensitive": caseInsensitive,
}})
}
func (t *Toolbox) RunBashCommand(ctx context.Context, command string) ToolResult {
return t.runBashCommand(ctx, ToolCallRequest{Name: "run_bash_command", Arguments: map[string]any{
"command": command,
}})
}

View File

@@ -0,0 +1,437 @@
package sandboxfs
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// This file implements the run_bash_command tool's sandbox. The name is
// historical: nothing here ever invokes a shell. The model's command
// string is tokenized into an argv slice and executed directly with
// os/exec, so there is no shell to inject into.
//
// Layers of defense, outermost first:
//
// 1. Tokenizer: a command string containing an unquoted shell
// metacharacter (| & ; < > ` $ or a newline) is rejected with a clear
// message instead of being silently passed through as literal
// arguments, which would confuse the model.
// 2. Allowlist on the base binary ONLY (first token, no path separators).
// Subcommand-level restrictions (go, git) are enforced separately, in
// code, below - never by string-matching the allowlist.
// 3. Per-binary flag denylist for flags that execute other programs or
// follow symlinks (find -exec, go test -exec, grep -R, ...).
// 4. Path validation: every argument that could be a path is resolved
// (symlinks included) and must land inside WorkspaceRoot.
// 5. A scrubbed environment (no DB DSN / SMTP / Stripe secrets reach the
// child), a wall-clock timeout, and a cap on captured output.
//
// Residual risk, stated plainly: `go test` and `go build` compile and run
// workspace code, and that code can do anything the enkly process can.
// Run enkly in a container (docker-compose.yml does) for real isolation.
// DefaultAllowedCommands is the default set of base binaries the agent may
// run. Override with ENKLY_BASH_ALLOWLIST (comma-separated base binaries).
var DefaultAllowedCommands = []string{"go", "git", "ls", "cat", "grep", "find", "gofmt"}
// allowedSubcommands restricts binaries that have dangerous subcommands.
// A binary that is not a key here has no subcommand restriction.
var allowedSubcommands = map[string]map[string]bool{
"go": {"build": true, "test": true, "vet": true, "fmt": true, "list": true, "version": true, "mod": true},
"git": {"status": true, "diff": true, "log": true, "show": true, "branch": true,
"rev-parse": true, "ls-files": true, "blame": true},
}
// deniedFlags are flags (matched exactly, or as a "--flag=value" prefix)
// that let an otherwise-harmless binary run other programs or follow
// symlinks out of the workspace.
var deniedFlags = map[string][]string{
"find": {"-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls", "-L", "-H", "-follow"},
"grep": {"-R", "--dereference-recursive"},
"go": {"-exec", "-toolexec", "-vettool", "-overlay", "-modfile"},
"git": {"--output", "--ext-diff", "--textconv", "-c", "--exec-path", "--git-dir", "--work-tree"},
}
// maxCommandOutput bounds how much stdout+stderr is captured. Anything
// beyond it is dropped with a marker, so a runaway `cat` of a huge file
// can't balloon memory or flood the model's context.
const maxCommandOutput = 64 * 1024
// ParseAllowlist parses ENKLY_BASH_ALLOWLIST. Entries are base binaries
// only; anything with a space or a path separator is rejected so the
// ambiguous "go vet" / "go test" style entries can't creep back in.
func ParseAllowlist(raw string) ([]string, error) {
var out []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.ContainsAny(part, " \t/\\") {
return nil, fmt.Errorf("allowlist entry %q must be a bare binary name (subcommand rules live in code)", part)
}
out = append(out, part)
}
if len(out) == 0 {
return nil, errors.New("allowlist is empty")
}
return out, nil
}
func (t *Toolbox) allowed(bin string) bool {
list := t.AllowedCommands
if len(list) == 0 {
list = DefaultAllowedCommands
}
for _, a := range list {
if a == bin {
return true
}
}
return false
}
// tokenize splits a command line into argv, honoring single quotes,
// double quotes and backslash escapes, and refusing unquoted shell
// metacharacters.
func tokenize(s string) ([]string, error) {
var (
args []string
cur strings.Builder
inTok bool
quote rune
escaped bool
)
flush := func() {
if inTok {
args = append(args, cur.String())
cur.Reset()
inTok = false
}
}
for _, r := range s {
switch {
case escaped:
cur.WriteRune(r)
inTok = true
escaped = false
case quote == '\'':
if r == '\'' {
quote = 0
} else {
cur.WriteRune(r)
}
case quote == '"':
switch r {
case '"':
quote = 0
case '\\':
escaped = true
case '$', '`':
return nil, fmt.Errorf("%q is not supported inside double quotes (no shell expansion happens here); use single quotes", string(r))
default:
cur.WriteRune(r)
}
case r == '\\':
escaped = true
inTok = true
case r == '\'' || r == '"':
quote = r
inTok = true
case r == ' ' || r == '\t':
flush()
case strings.ContainsRune("|&;<>`$\n\r*?", r):
return nil, fmt.Errorf("shell syntax %q is not supported: commands run directly, without a shell (no pipes, redirects, globbing, substitution or chaining) - run one simple command per call", string(r))
default:
cur.WriteRune(r)
inTok = true
}
}
if quote != 0 {
return nil, errors.New("unterminated quote")
}
if escaped {
return nil, errors.New("trailing backslash")
}
flush()
return args, nil
}
func flagDenied(bin, arg string) bool {
for _, d := range deniedFlags[bin] {
if arg == d || strings.HasPrefix(arg, d+"=") {
return true
}
}
return false
}
// checkArgs validates the argv (already known to start with an allowlisted
// binary) and returns a user-facing error describing the first violation.
func (t *Toolbox) checkArgs(argv []string) error {
bin := argv[0]
rest := argv[1:]
if subs, ok := allowedSubcommands[bin]; ok {
if len(rest) == 0 {
return fmt.Errorf("%s needs a subcommand; allowed: %s", bin, sortedKeys(subs))
}
if !subs[rest[0]] {
return fmt.Errorf("%s %s is not allowed; allowed subcommands: %s", bin, rest[0], sortedKeys(subs))
}
}
// grep's first positional argument is a regex, not a path (unless the
// pattern came from -e/--regexp, in which case every positional is a path).
skipPattern := bin == "grep"
for _, a := range rest {
if a == "-e" || strings.HasPrefix(a, "--regexp") {
skipPattern = false
}
}
for _, a := range rest {
if flagDenied(bin, a) {
return fmt.Errorf("flag %s is not allowed for %s", a, bin)
}
if strings.HasPrefix(a, "-") && len(a) > 1 {
// Flag. Validate a value glued on with "=" or a slash.
if k := strings.Index(a, "="); k >= 0 {
if err := t.checkPathArg(a[k+1:]); err != nil {
return err
}
} else if k := strings.Index(a, "/"); k >= 0 {
if err := t.checkPathArg(a[k:]); err != nil {
return err
}
}
continue
}
if skipPattern {
// The first non-flag argument to grep is the pattern.
skipPattern = false
continue
}
// `go test ./...`-style package patterns and git revisions are not
// files, but they are also harmless to run through the path check:
// they resolve inside the workspace or don't exist.
if err := t.checkPathArg(a); err != nil {
return err
}
}
return nil
}
func sortedKeys(m map[string]bool) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// checkPathArg rejects an argument that resolves outside WorkspaceRoot.
// Arguments that are not paths at all (a package name, a git revision)
// simply don't exist on disk and pass as long as they don't look like an
// escape (absolute or containing "..").
func (t *Toolbox) checkPathArg(arg string) error {
if arg == "" {
return nil
}
root, err := filepath.EvalSymlinks(t.WorkspaceRoot)
if err != nil {
return fmt.Errorf("workspace root: %w", err)
}
var full string
if filepath.IsAbs(arg) {
full = filepath.Clean(arg)
} else {
full = filepath.Join(t.WorkspaceRoot, arg)
}
looksLikeEscape := filepath.IsAbs(arg) || hasDotDot(arg)
resolved, exists := evalExisting(full)
if !exists && !looksLikeEscape {
return nil
}
if !within(root, resolved) {
return fmt.Errorf("argument %q resolves outside the workspace; use paths inside the project", arg)
}
return nil
}
func hasDotDot(p string) bool {
for _, seg := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) {
if seg == ".." {
return true
}
}
return false
}
// evalExisting resolves symlinks in the longest existing prefix of p and
// re-appends the non-existent tail. exists reports whether the whole path
// exists.
func evalExisting(p string) (resolved string, exists bool) {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r, true
}
tail := ""
cur := p
for {
parent := filepath.Dir(cur)
tail = filepath.Join(filepath.Base(cur), tail)
if r, err := filepath.EvalSymlinks(parent); err == nil {
return filepath.Join(r, tail), false
}
if parent == cur {
return p, false
}
cur = parent
}
}
// within reports whether path is root itself or nested under it, comparing
// whole path segments (so /ws-evil is NOT within /ws).
func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// cappedBuffer is a mutex-guarded writer that stops growing at max bytes.
type cappedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
max int
truncated bool
}
func (c *cappedBuffer) Write(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
room := c.max - c.buf.Len()
if room <= 0 {
c.truncated = true
return len(p), nil // swallow, so the child never blocks on a full pipe
}
if len(p) > room {
c.buf.Write(p[:room])
c.truncated = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *cappedBuffer) String() string {
c.mu.Lock()
defer c.mu.Unlock()
s := strings.ToValidUTF8(c.buf.String(), "\uFFFD")
if c.truncated {
s += fmt.Sprintf("\n... [output truncated at %d bytes]", c.max)
}
return s
}
// scrubbedEnv builds the child's environment from a small allowlist so
// secrets held by the enkly process (DB DSN, SMTP and Stripe keys) never
// reach a command the model controls.
func (t *Toolbox) scrubbedEnv() []string {
keep := []string{"PATH", "HOME", "LANG", "TMPDIR", "GOPATH", "GOCACHE", "GOMODCACHE", "GOROOT"}
env := make([]string, 0, len(keep)+6)
for _, k := range keep {
if v, ok := os.LookupEnv(k); ok {
env = append(env, k+"="+v)
}
}
env = append(env,
"GOPROXY=off", // the stack must not need the internet
"GOTOOLCHAIN=local",
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_NOSYSTEM=1",
"GIT_PAGER=cat",
"PAGER=cat",
)
return env
}
func (t *Toolbox) runBashCommand(ctx context.Context, call ToolCallRequest) ToolResult {
fail := func(format string, a ...any) ToolResult {
return ToolResult{Name: call.Name, Content: "run_bash_command: " + fmt.Sprintf(format, a...), IsError: true}
}
command, _ := call.Arguments["command"].(string)
if strings.TrimSpace(command) == "" {
return fail("empty command")
}
argv, err := tokenize(command)
if err != nil {
return fail("%v", err)
}
if len(argv) == 0 {
return fail("empty command")
}
bin := argv[0]
if strings.ContainsAny(bin, `/\`) {
return fail("run the binary by name (e.g. %q), not by path", filepath.Base(bin))
}
if !t.allowed(bin) {
list := t.AllowedCommands
if len(list) == 0 {
list = DefaultAllowedCommands
}
return fail("%q is not an allowed command; allowed: %s", bin, strings.Join(list, ", "))
}
if err := t.checkArgs(argv); err != nil {
return fail("%v", err)
}
timeout := t.CommandTimeout
if timeout <= 0 {
timeout = DefaultCommandTimeout
}
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// argv slice, never a shell string.
cmd := exec.CommandContext(cmdCtx, bin, argv[1:]...)
cmd.Dir = t.WorkspaceRoot
cmd.Env = t.scrubbedEnv()
cmd.WaitDelay = 2 * time.Second
out := &cappedBuffer{max: maxCommandOutput}
cmd.Stdout = out
cmd.Stderr = out
runErr := cmd.Run()
text := out.String()
if runErr != nil {
msg := runErr.Error()
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
msg = fmt.Sprintf("timed out after %s", timeout)
} else if errors.Is(runErr, exec.ErrNotFound) {
msg = fmt.Sprintf("%s is not installed in this environment", bin)
}
return ToolResult{
Name: call.Name,
Content: fmt.Sprintf("exit error: %s\n\n%s", msg, text),
IsError: true,
}
}
return ToolResult{Name: call.Name, Content: text}
}

View File

@@ -0,0 +1,764 @@
package sandboxfs
import (
"bufio"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// ignoredDirNames are skipped by every tool that walks the workspace tree
// (list_directory, glob_search, grep) — dependency/build/VCS directories
// that are almost never useful to an agent browsing a codebase and can be
// enormous (node_modules, vendor, .git), which would otherwise blow past
// the result caps below on the first call.
var ignoredDirNames = map[string]bool{
".git": true, "node_modules": true, "vendor": true, ".venv": true,
"venv": true, "__pycache__": true, "dist": true, "build": true,
".idea": true, ".vscode": true, "target": true, ".next": true,
}
const (
// maxReadLines/maxReadFileBytes bound read_file: a local model's
// context window is small relative to a real codebase, so reading an
// entire large generated file (a lockfile, a minified bundle) would
// silently crowd out everything else in the conversation. offset/
// limit let the model page through a large file deliberately instead.
maxReadLines = 2000
maxReadFileSize = 5 * 1024 * 1024 // 5 MiB
maxWalkEntries = 500 // cap for list_directory / glob_search results
maxGrepMatches = 200
maxGrepFileSize = 2 * 1024 * 1024
// diffContextLines is how many unchanged lines of context are shown
// before and after each changed block in the diff preview that
// edit_file/multi_edit_file return — enough to orient a reviewer
// (human or model) without dumping the whole file back into context,
// mirroring how a frontier coding agent confirms an edit.
diffContextLines = 4
// maxHunkChangedLines caps how many changed (-/+) lines a single hunk
// prints before eliding the middle, so replacing a huge block doesn't
// flood the tool result.
maxHunkChangedLines = 120
// maxDiffHunksPerEdit caps how many occurrences of a replace_all edit
// get a full diff hunk; beyond that, later occurrences are just
// counted, not rendered.
maxDiffHunksPerEdit = 8
)
// Toolbox executes the filesystem/bash tools on the host machine,
// jailed to WorkspaceRoot. This is the same Toolbox the monolith's
// internal/llm package had — minus Defs()/Execute() (tool-schema
// presentation and dispatch are now the agent service's job; see its
// internal/infrastructure/toolcatalog) and minus the Searcher field
// (search_codebase is now a direct agent-to-knowledge-service gRPC
// call, not something the workspace service's toolbox mediates).
type Toolbox struct {
WorkspaceRoot string
// CommandTimeout bounds how long run_bash_command may run. Overridable
// per Toolbox (see NewToolbox / ENKLY_BASH_TIMEOUT_SECONDS in main.go)
// since a "go test ./..." or a from-scratch npm install can easily
// outrun a timeout tuned for quick commands.
CommandTimeout time.Duration
// AllowedCommands is the allowlist of base binaries run_bash_command
// may execute (first token only; see sandbox.go). Empty means
// DefaultAllowedCommands.
AllowedCommands []string
}
// DefaultCommandTimeout is how long run_bash_command may run when
// ENKLY_BASH_TIMEOUT_SECONDS isn't set — long enough for a typical build
// or test run, short enough that a genuinely hung command (e.g. a
// process waiting on stdin) doesn't stall the whole agent turn.
const DefaultCommandTimeout = 60 * time.Second
// NewToolbox builds a Toolbox rooted at workspaceRoot (created if missing).
func NewToolbox(workspaceRoot string) (*Toolbox, error) {
abs, err := filepath.Abs(workspaceRoot)
if err != nil {
return nil, fmt.Errorf("tools: resolve workspace root: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("tools: create workspace root: %w", err)
}
return &Toolbox{
WorkspaceRoot: abs,
CommandTimeout: DefaultCommandTimeout,
}, nil
}
func (t *Toolbox) resolvePath(rel string) (string, error) {
cleaned := filepath.Clean("/" + rel) // force it to look absolute-from-root, collapsing ".."
full := filepath.Join(t.WorkspaceRoot, cleaned)
// Compare whole path segments: a plain HasPrefix would accept a sibling
// directory like "<root>-evil".
if !within(t.WorkspaceRoot, full) {
return "", fmt.Errorf("path %q escapes the workspace root", rel)
}
// Symlinks inside the workspace must not lead out of it.
realRoot, err := filepath.EvalSymlinks(t.WorkspaceRoot)
if err != nil {
return "", fmt.Errorf("workspace root: %w", err)
}
if resolved, _ := evalExisting(full); !within(realRoot, resolved) {
return "", fmt.Errorf("path %q escapes the workspace root via a symlink", rel)
}
return full, nil
}
// ResolvePath is the exported form of resolvePath, for callers outside
// this package that need the exact same jail the read_file/write_file/
// etc. tools use — namely the file upload/download HTTP handlers in
// internal/handlers, which save/serve files under WorkspaceRoot and must
// never let a client-supplied path escape it. Keeping one implementation
// here (rather than a second copy of the containment check in handlers)
// means both the agent's tools and the HTTP endpoints stay in lockstep if
// the jailing logic ever changes.
func (t *Toolbox) ResolvePath(rel string) (string, error) {
return t.resolvePath(rel)
}
// argInt reads an optional numeric argument. A tool call's "arguments"
// JSON string is decoded via encoding/json into map[string]any, which
// always produces float64 for a JSON number regardless of the schema's
// declared type, so this accepts float64 (the common case) and falls
// back to a string (seen from some models that emit stringified
// numbers) before giving up.
func argInt(args map[string]any, key string) (int, bool) {
v, ok := args[key]
if !ok || v == nil {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case string:
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return i, true
}
}
return 0, false
}
func argBool(args map[string]any, key string) bool {
v, _ := args[key].(bool)
return v
}
func (t *Toolbox) readFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
info, err := os.Stat(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
}
if info.IsDir() {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is a directory, not a file", relPath), IsError: true}
}
if info.Size() > maxReadFileSize {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %q is %d bytes, larger than the %d-byte limit — use run_bash_command (e.g. sed/head/grep) to inspect it in pieces instead", relPath, info.Size(), maxReadFileSize), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: %v", err), IsError: true}
}
offset, _ := argInt(call.Arguments, "offset")
if offset < 1 {
offset = 1
}
limit, ok := argInt(call.Arguments, "limit")
if !ok || limit <= 0 || limit > maxReadLines {
limit = maxReadLines
}
lines := strings.Split(string(data), "\n")
if offset > len(lines) {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("read_file: offset %d is past end of file (%d lines)", offset, len(lines)), IsError: true}
}
end := offset - 1 + limit
if end > len(lines) {
end = len(lines)
}
var sb strings.Builder
for i := offset - 1; i < end; i++ {
fmt.Fprintf(&sb, "%6d\t%s\n", i+1, lines[i])
}
if end < len(lines) {
fmt.Fprintf(&sb, "\n… %d more lines not shown; re-call read_file with offset=%d to continue …\n", len(lines)-end, end+1)
}
return ToolResult{Name: call.Name, Content: sb.String()}
}
func (t *Toolbox) writeFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
content, _ := call.Arguments["content"].(string)
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: mkdir: %v", err), IsError: true}
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("write_file: %v", err), IsError: true}
}
return ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d bytes to %s", len(content), relPath)}
}
func (t *Toolbox) editFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
oldStr, _ := call.Arguments["old_string"].(string)
newStr, _ := call.Arguments["new_string"].(string)
replaceAll := argBool(call.Arguments, "replace_all")
if oldStr == "" {
return ToolResult{Name: call.Name, Content: "edit_file: old_string must not be empty (use write_file to create a new file)", IsError: true}
}
if oldStr == newStr {
return ToolResult{Name: call.Name, Content: "edit_file: old_string and new_string are identical — nothing to do", IsError: true}
}
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: %v", err), IsError: true}
}
content := string(data)
count := strings.Count(content, oldStr)
switch {
case count == 0:
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string not found in %s — it must match the file's current content exactly (re-read the file if you're not sure)", relPath), IsError: true}
case count > 1 && !replaceAll:
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: old_string appears %d times in %s, so the edit is ambiguous — include more surrounding context to make it unique, or pass replace_all=true if you really mean every occurrence", count, relPath), IsError: true}
}
updated, hunks := applyEditWithDiff(content, oldStr, newStr, replaceAll)
if err := os.WriteFile(full, []byte(updated), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("edit_file: write: %v", err), IsError: true}
}
verb := "1 occurrence"
if replaceAll && count > 1 {
verb = fmt.Sprintf("%d occurrences", count)
}
header := fmt.Sprintf("edited %s: replaced %s\n\n", relPath, verb)
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(hunks)}
}
// multiEditFile applies a sequence of edit_file-style replacements to one
// file as a single all-or-nothing operation. Each edit is checked against
// the content as it stands after every prior edit in the batch, so later
// edits can depend on earlier ones (e.g. renaming a symbol's definition,
// then a call site that only exists after the first edit) — but the
// moment any edit fails, the function returns without writing anything,
// so a batch that's wrong in step 4 of 5 can never leave the file
// half-edited.
func (t *Toolbox) multiEditFile(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
rawEdits, ok := call.Arguments["edits"].([]any)
if !ok || len(rawEdits) == 0 {
return ToolResult{Name: call.Name, Content: "multi_edit_file: \"edits\" must be a non-empty array", IsError: true}
}
type editSpec struct {
old, new string
replaceAll bool
}
edits := make([]editSpec, 0, len(rawEdits))
for i, raw := range rawEdits {
m, ok := raw.(map[string]any)
if !ok {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d] must be an object", i), IsError: true}
}
oldStr, _ := m["old_string"].(string)
newStr, _ := m["new_string"].(string)
if oldStr == "" {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string must not be empty", i), IsError: true}
}
if oldStr == newStr {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: edits[%d]: old_string and new_string are identical — nothing to do", i), IsError: true}
}
edits = append(edits, editSpec{old: oldStr, new: newStr, replaceAll: argBool(m, "replace_all")})
}
full, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
data, err := os.ReadFile(full)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: %v", err), IsError: true}
}
content := string(data)
var allHunks []string
totalReplacements := 0
for i, e := range edits {
count := strings.Count(content, e.old)
switch {
case count == 0:
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
"multi_edit_file: edits[%d]: old_string not found (after applying edits[0:%d]) — no changes were written to %s; the whole batch is atomic, so fix this edit and retry from the top", i, i, relPath), IsError: true}
case count > 1 && !e.replaceAll:
return ToolResult{Name: call.Name, Content: fmt.Sprintf(
"multi_edit_file: edits[%d]: old_string appears %d times (after applying edits[0:%d]), so it's ambiguous — no changes were written to %s; add context or set this edit's replace_all", i, count, i, relPath), IsError: true}
}
updated, hunks := applyEditWithDiff(content, e.old, e.new, e.replaceAll)
allHunks = append(allHunks, hunks...)
content = updated
if e.replaceAll {
totalReplacements += count
} else {
totalReplacements++
}
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("multi_edit_file: write: %v", err), IsError: true}
}
header := fmt.Sprintf("edited %s: applied %d edit(s), %d replacement(s)\n\n", relPath, len(edits), totalReplacements)
return ToolResult{Name: call.Name, Content: header + renderDiffHunks(allHunks)}
}
// applyEditWithDiff replaces old with new in content — every occurrence if
// replaceAll, otherwise the sole occurrence (callers must already have
// checked count/ambiguity themselves) — and returns the updated content
// alongside a unified-diff-style hunk for each replacement performed. This
// is shared by edit_file and multi_edit_file so both tools report changes
// the same way.
func applyEditWithDiff(content, oldStr, newStr string, replaceAll bool) (updated string, hunks []string) {
if !replaceAll {
idx := strings.Index(content, oldStr)
if idx < 0 { // shouldn't happen; callers check first, but stay safe
return content, nil
}
return content[:idx] + newStr + content[idx+len(oldStr):], []string{diffHunk(content, idx, oldStr, newStr)}
}
total := strings.Count(content, oldStr)
var sb strings.Builder
pos, shown := 0, 0
for {
idx := strings.Index(content[pos:], oldStr)
if idx < 0 {
break
}
abs := pos + idx
if shown < maxDiffHunksPerEdit {
hunks = append(hunks, diffHunk(content, abs, oldStr, newStr))
shown++
}
sb.WriteString(content[pos:abs])
sb.WriteString(newStr)
pos = abs + len(oldStr)
}
sb.WriteString(content[pos:])
if total > shown {
hunks = append(hunks, fmt.Sprintf("… %d further occurrence(s) replaced but not individually shown …", total-shown))
}
return sb.String(), hunks
}
// renderDiffHunks joins hunks with a small separator so multiple
// non-adjacent changes in one tool result read clearly as distinct
// hunks rather than one continuous block.
func renderDiffHunks(hunks []string) string {
if len(hunks) == 0 {
return ""
}
return strings.Join(hunks, " ⋮\n")
}
// diffHunk renders one unified-diff-style hunk for replacing old with new
// at byte offset `at` in content, expanded outward to whole lines (so a
// hunk never splits a line mid-way even though old/new themselves may
// start or end mid-line) and padded with up to diffContextLines lines of
// unchanged context on each side. It's built directly from the known
// old/new span — rather than a general line-diff algorithm — since
// edit_file's contract already guarantees the exact substring replaced.
func diffHunk(content string, at int, oldStr, newStr string) string {
lineStart, lineEnd := wholeLineRange(content, at, len(oldStr))
oldBlock := content[lineStart:lineEnd]
newBlock := oldBlock[:at-lineStart] + newStr + oldBlock[at-lineStart+len(oldStr):]
ctxStart := expandLinesBack(content, lineStart, diffContextLines)
ctxEnd := expandLinesForward(content, lineEnd, diffContextLines)
lineNo := strings.Count(content[:ctxStart], "\n") + 1
var sb strings.Builder
lineNo = writeDiffBlock(&sb, linesOf(content[ctxStart:lineStart]), " ", lineNo)
oldEnd := writeDiffBlock(&sb, linesOf(oldBlock), "-", lineNo)
writeDiffBlock(&sb, linesOf(newBlock), "+", lineNo)
writeDiffBlock(&sb, linesOf(content[lineEnd:ctxEnd]), " ", oldEnd)
return sb.String()
}
// wholeLineRange returns the [start,end) byte offsets of the smallest run
// of whole lines in content that fully contains the byte range
// [at, at+length), including the trailing newline of the last line (or
// running to EOF if there isn't one).
func wholeLineRange(content string, at, length int) (start, end int) {
start = strings.LastIndexByte(content[:at], '\n') + 1 // 0 if none found
end = at + length
if idx := strings.IndexByte(content[end:], '\n'); idx >= 0 {
end += idx + 1
} else {
end = len(content)
}
return start, end
}
func expandLinesBack(content string, pos, n int) int {
for i := 0; i < n && pos > 0; i++ {
pos = strings.LastIndexByte(content[:pos-1], '\n') + 1
}
return pos
}
func expandLinesForward(content string, pos, n int) int {
for i := 0; i < n && pos < len(content); i++ {
idx := strings.IndexByte(content[pos:], '\n')
if idx < 0 {
return len(content)
}
pos += idx + 1
}
return pos
}
// linesOf splits a whole-lines block (as produced by wholeLineRange) into
// its constituent lines, dropping the trailing empty element a final "\n"
// would otherwise produce.
func linesOf(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
// writeDiffBlock prints lines with a diff marker ("-", "+", or " ") and
// 1-based line numbers, eliding the middle of very long blocks (see
// maxHunkChangedLines) so a giant replacement doesn't flood the result.
// It returns the next line number after this block.
func writeDiffBlock(sb *strings.Builder, lines []string, marker string, lineNo int) int {
if len(lines) > maxHunkChangedLines {
half := maxHunkChangedLines / 2
for i := 0; i < half; i++ {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
}
fmt.Fprintf(sb, " %s … %d lines omitted …\n", marker, len(lines)-maxHunkChangedLines)
for i := len(lines) - half; i < len(lines); i++ {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, lines[i])
}
} else {
for i, l := range lines {
fmt.Fprintf(sb, "%6d %s %s\n", lineNo+i, marker, l)
}
}
return lineNo + len(lines)
}
func (t *Toolbox) listDirectory(call ToolCallRequest) ToolResult {
relPath, _ := call.Arguments["path"].(string)
if strings.TrimSpace(relPath) == "" {
relPath = "."
}
maxDepth, ok := argInt(call.Arguments, "max_depth")
if !ok || maxDepth <= 0 {
maxDepth = 3
}
root, err := t.resolvePath(relPath)
if err != nil {
return ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %q is not a directory in this workspace", relPath), IsError: true}
}
type entry struct {
rel string
isDir bool
}
var entries []entry
truncated := false
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries rather than failing the whole listing
}
if path == root {
return nil
}
rel, _ := filepath.Rel(root, path)
depth := strings.Count(rel, string(filepath.Separator)) + 1
if d.IsDir() && ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
if depth > maxDepth {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if len(entries) >= maxWalkEntries {
truncated = true
return filepath.SkipAll
}
entries = append(entries, entry{rel: filepath.ToSlash(rel), isDir: d.IsDir()})
return nil
})
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("list_directory: %v", err), IsError: true}
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].isDir != entries[j].isDir {
return entries[i].isDir // directories first
}
return entries[i].rel < entries[j].rel
})
var sb strings.Builder
fmt.Fprintf(&sb, "%s/\n", strings.TrimSuffix(relPath, "/"))
for _, e := range entries {
suffix := ""
if e.isDir {
suffix = "/"
}
fmt.Fprintf(&sb, " %s%s\n", e.rel, suffix)
}
if len(entries) == 0 {
sb.WriteString(" (empty)\n")
}
if truncated {
fmt.Fprintf(&sb, "\n… output truncated at %d entries; narrow `path` or `max_depth` for a fuller view of one area …\n", maxWalkEntries)
}
return ToolResult{Name: call.Name, Content: sb.String()}
}
// segmentToRegexp converts one path segment (no "/" in it) of a glob into
// regexp source, treating `*` as "within this segment" and `?` as a single
// rune, and escaping everything else.
func segmentToRegexp(seg string) string {
var sb strings.Builder
for _, r := range seg {
switch r {
case '*':
sb.WriteString("[^/]*")
case '?':
sb.WriteString("[^/]")
default:
sb.WriteString(regexp.QuoteMeta(string(r)))
}
}
return sb.String()
}
// globToRegexp compiles a shell-style glob into an anchored regexp over
// forward-slash-separated relative paths. `*` matches within one path
// segment; `?` matches a single rune within a segment; a `**` segment
// matches zero or more whole path segments, so "**/*.go" matches both
// "main.go" at the root and "internal/llm/tools.go" — the standard
// "globstar" behavior, which a naive character-by-character translation
// (treating `**` as ".*" in place) gets wrong for the common leading-`**/`
// case, since it would then require a literal "/" for a root-level match.
func globToRegexp(pattern string) (*regexp.Regexp, error) {
pattern = filepath.ToSlash(strings.TrimPrefix(pattern, "./"))
segments := strings.Split(pattern, "/")
var sb strings.Builder
sb.WriteString("^")
for i, seg := range segments {
last := i == len(segments)-1
if seg == "**" {
if last {
sb.WriteString(".*") // trailing "**" matches everything under here, any depth
} else {
sb.WriteString(`(?:[^/]+/)*`) // zero or more whole segments, each including its "/"
}
continue
}
sb.WriteString(segmentToRegexp(seg))
if !last {
sb.WriteString("/")
}
}
sb.WriteString("$")
return regexp.Compile(sb.String())
}
func (t *Toolbox) globSearch(call ToolCallRequest) ToolResult {
pattern, _ := call.Arguments["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return ToolResult{Name: call.Name, Content: "glob_search: empty pattern", IsError: true}
}
re, err := globToRegexp(pattern)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: invalid pattern: %v", err), IsError: true}
}
var matches []string
truncated := false
err = filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == t.WorkspaceRoot {
return nil
}
if d.IsDir() {
if ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
rel = filepath.ToSlash(rel)
if !re.MatchString(rel) {
return nil
}
if len(matches) >= maxWalkEntries {
truncated = true
return filepath.SkipAll
}
matches = append(matches, rel)
return nil
})
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("glob_search: %v", err), IsError: true}
}
sort.Strings(matches)
if len(matches) == 0 {
return ToolResult{Name: call.Name, Content: "no files matched that pattern"}
}
out := strings.Join(matches, "\n")
if truncated {
out += fmt.Sprintf("\n\n… truncated at %d matches; narrow the pattern for a fuller view …", maxWalkEntries)
}
return ToolResult{Name: call.Name, Content: out}
}
func (t *Toolbox) grep(call ToolCallRequest) ToolResult {
pattern, _ := call.Arguments["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return ToolResult{Name: call.Name, Content: "grep: empty pattern", IsError: true}
}
if argBool(call.Arguments, "case_insensitive") && !strings.HasPrefix(pattern, "(?i)") {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid regular expression: %v", err), IsError: true}
}
var pathRe *regexp.Regexp
if pathGlob, _ := call.Arguments["path_glob"].(string); strings.TrimSpace(pathGlob) != "" {
pathRe, err = globToRegexp(pathGlob)
if err != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: invalid path_glob: %v", err), IsError: true}
}
}
var sb strings.Builder
matchCount := 0
stop := false
walkErr := filepath.WalkDir(t.WorkspaceRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || stop {
return nil
}
if d.IsDir() {
if path != t.WorkspaceRoot && ignoredDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(t.WorkspaceRoot, path)
rel = filepath.ToSlash(rel)
if pathRe != nil && !pathRe.MatchString(rel) {
return nil
}
info, err := d.Info()
if err != nil || info.Size() > maxGrepFileSize || info.Size() == 0 {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNo := 0
for scanner.Scan() {
lineNo++
line := scanner.Text()
if lineNo == 1 && strings.ContainsRune(line, 0) {
break // looks binary (NUL byte on the first line) — skip it
}
if re.MatchString(line) {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 300 {
trimmed = trimmed[:300] + "…"
}
fmt.Fprintf(&sb, "%s:%d: %s\n", rel, lineNo, trimmed)
matchCount++
if matchCount >= maxGrepMatches {
stop = true
return filepath.SkipAll
}
}
}
return nil
})
if walkErr != nil {
return ToolResult{Name: call.Name, Content: fmt.Sprintf("grep: %v", walkErr), IsError: true}
}
if matchCount == 0 {
return ToolResult{Name: call.Name, Content: "no matches found"}
}
out := sb.String()
if stop {
out += fmt.Sprintf("\n… truncated at %d matches; narrow `pattern` or `path_glob` …\n", maxGrepMatches)
}
return ToolResult{Name: call.Name, Content: out}
}
// writeTodos doesn't touch the filesystem at all — it just validates and
// reformats the model's plan into a checklist. The tool call + result is
// persisted to the messages/tool_calls tables exactly like any other tool
// (see internal/handlers' chat flow), which is what makes the plan visible
// to the user as it updates, and gives the model a compact record of its
// own plan to refer back to on later rounds.

View File

@@ -0,0 +1,21 @@
package sandboxfs
// ToolCallRequest and ToolResult are local equivalents of the
// monolith's internal/models.ToolCallRequest/ToolResult — kept as
// plain structs here (rather than importing a shared models package)
// so this package's only dependency is the Go standard library, the
// same isolation internal/llm/tools.go itself had from internal/db,
// internal/handlers, etc. The workspace gRPC server (see
// internal/interfaces/grpc) is what actually constructs/consumes
// these, translating to and from the generated workspacev1 proto
// types.
type ToolCallRequest struct {
Name string
Arguments map[string]any
}
type ToolResult struct {
Name string
Content string
IsError bool
}

View File

@@ -0,0 +1,189 @@
package grpc
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"google.golang.org/protobuf/types/known/timestamppb"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/grpcerr"
)
// ignoredWorkspaceDirNames mirrors sandboxfs's own ignoredDirNames
// (unexported there): dependency/build/VCS directories that are noise
// in a file *listing* the same way they're noise for the agent's own
// list_directory/glob_search tools. Display-only — nothing security
// sensitive depends on it, since every path below still goes through
// Toolbox.ResolvePath regardless of what Tree skips.
var ignoredWorkspaceDirNames = map[string]bool{
".git": true, "node_modules": true, "vendor": true, ".venv": true,
"venv": true, "__pycache__": true, "dist": true, "build": true,
".idea": true, ".vscode": true, "target": true, ".next": true,
}
// maxWorkspaceFilesListed caps how many files Tree returns, mirroring
// internal/handlers/files.go's identical cap for the same reason: a
// workspace with an enormous tree shouldn't produce an enormous
// sidebar (or an enormous gRPC response).
const maxWorkspaceFilesListed = 500
// UploadFile replaces Handlers.saveUploadedFile. The gateway has
// already done every HTTP-specific part (multipart parsing, per-file
// size limits, CSRF, stripping any directory component the browser
// supplied) — see services/gateway/internal/handlers/files.go — and
// hands this RPC just a clean relative destination path and the bytes.
func (s *Server) UploadFile(ctx context.Context, req *workspacev1.UploadFileRequest) (*workspacev1.UploadFileResponse, error) {
full, err := s.Toolbox.ResolvePath(req.GetPath())
if err != nil {
return nil, grpcerr.InvalidArgument(err.Error())
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return nil, grpcerr.Internal(err)
}
if err := os.WriteFile(full, req.GetContent(), 0o644); err != nil {
return nil, grpcerr.Internal(err)
}
return &workspacev1.UploadFileResponse{BytesWritten: int64(len(req.GetContent()))}, nil
}
// DownloadFile replaces Handlers.DownloadFile's file-reading half (the
// gateway still owns the actual HTTP streaming/Content-Disposition
// response).
func (s *Server) DownloadFile(ctx context.Context, req *workspacev1.DownloadFileRequest) (*workspacev1.DownloadFileResponse, error) {
full, err := s.Toolbox.ResolvePath(req.GetPath())
if err != nil {
return nil, grpcerr.NotFound("file")
}
info, err := os.Stat(full)
if err != nil || info.IsDir() {
return nil, grpcerr.NotFound("file")
}
data, err := os.ReadFile(full)
if err != nil {
return nil, grpcerr.Internal(err)
}
return &workspacev1.DownloadFileResponse{Content: data, Filename: filepath.Base(full)}, nil
}
// Tree replaces Handlers.listWorkspaceFiles (one_level = false, the
// default) AND the directory-listing half of Handlers.FileTree
// (one_level = true) — see TreeRequest's doc comment in the proto.
func (s *Server) Tree(ctx context.Context, req *workspacev1.TreeRequest) (*workspacev1.TreeResponse, error) {
if req.GetOneLevel() {
return s.treeOneLevel(req.GetPath())
}
return s.treeRecursive()
}
func (s *Server) treeOneLevel(relDir string) (*workspacev1.TreeResponse, error) {
full, err := s.Toolbox.ResolvePath(relDir)
if err != nil {
return nil, grpcerr.InvalidArgument(err.Error())
}
info, err := os.Stat(full)
if err != nil || !info.IsDir() {
return nil, grpcerr.NotFound("directory")
}
entries, err := os.ReadDir(full)
if err != nil {
return nil, grpcerr.Internal(err)
}
var nodes []*workspacev1.TreeNode
for _, e := range entries {
if e.Type()&fs.ModeSymlink != 0 {
continue
}
name := e.Name()
if e.IsDir() && ignoredWorkspaceDirNames[name] {
continue
}
childRel := name
if relDir != "" {
childRel = relDir + "/" + name
}
node := &workspacev1.TreeNode{Name: name, RelPath: childRel, IsDir: e.IsDir()}
if !e.IsDir() {
if fi, err := e.Info(); err == nil {
node.Size = fi.Size()
node.ModifiedAt = timestamppb.New(fi.ModTime())
}
}
nodes = append(nodes, node)
}
// Directories first, then files, each alphabetical — matches
// readTreeChildren's ordering exactly.
sort.SliceStable(nodes, func(i, j int) bool {
if nodes[i].IsDir != nodes[j].IsDir {
return nodes[i].IsDir
}
return nodes[i].Name < nodes[j].Name
})
if len(nodes) > maxWorkspaceFilesListed {
nodes = nodes[:maxWorkspaceFilesListed]
}
return &workspacev1.TreeResponse{Nodes: nodes}, nil
}
func (s *Server) treeRecursive() (*workspacev1.TreeResponse, error) {
root := s.Toolbox.WorkspaceRoot
var nodes []*workspacev1.TreeNode
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries rather than failing the whole listing
}
if p == root {
return nil
}
if d.IsDir() {
if ignoredWorkspaceDirNames[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(root, p)
if relErr != nil {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
rel = filepath.ToSlash(rel)
nodes = append(nodes, &workspacev1.TreeNode{
Name: d.Name(), RelPath: rel, IsDir: false, Size: info.Size(),
ModifiedAt: timestamppb.New(info.ModTime()),
})
if len(nodes) >= maxWorkspaceFilesListed {
return filepath.SkipAll
}
return nil
})
if err != nil {
return nil, grpcerr.Internal(err)
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].RelPath < nodes[j].RelPath })
return &workspacev1.TreeResponse{Nodes: nodes}, nil
}
// StatFile replaces the Agent.Toolbox.ResolvePath + os.Stat pairing
// internal/handlers/deliverables.go used to decide whether a tool call
// "produced" a downloadable file.
func (s *Server) StatFile(ctx context.Context, req *workspacev1.StatFileRequest) (*workspacev1.StatFileResponse, error) {
full, err := s.Toolbox.ResolvePath(req.GetPath())
if err != nil {
return &workspacev1.StatFileResponse{Exists: false}, nil
}
info, err := os.Stat(full)
if err != nil {
return &workspacev1.StatFileResponse{Exists: false}, nil
}
return &workspacev1.StatFileResponse{
Exists: true, IsDir: info.IsDir(), Size: info.Size(),
ModifiedAt: timestamppb.New(info.ModTime()),
}, nil
}

View File

@@ -0,0 +1,97 @@
// Package grpc adapts sandboxfs.Toolbox onto the generated
// workspacev1.WorkspaceServiceServer interface. Every RPC here either
// replaces one agent tool (internal/llm/tools.go) or one human-facing
// files-panel endpoint (internal/handlers/files.go) from the monolith —
// see each method's doc comment.
package grpc
import (
"context"
workspacev1 "enkly/gen/workspace/v1"
"enkly/services/workspace/internal/infrastructure/sandboxfs"
)
type Server struct {
workspacev1.UnimplementedWorkspaceServiceServer
Toolbox *sandboxfs.Toolbox
}
func New(tb *sandboxfs.Toolbox) *Server { return &Server{Toolbox: tb} }
func (s *Server) ReadFile(ctx context.Context, req *workspacev1.ReadFileRequest) (*workspacev1.ReadFileResponse, error) {
res := s.Toolbox.ReadFile(req.GetPath(), int(req.GetOffset()), int(req.GetLimit()))
if res.IsError {
return &workspacev1.ReadFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.ReadFileResponse{Content: res.Content}, nil
}
func (s *Server) WriteFile(ctx context.Context, req *workspacev1.WriteFileRequest) (*workspacev1.WriteFileResponse, error) {
res := s.Toolbox.WriteFile(req.GetPath(), req.GetContent())
if res.IsError {
return &workspacev1.WriteFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.WriteFileResponse{BytesWritten: int64(len(req.GetContent()))}, nil
}
func (s *Server) EditFile(ctx context.Context, req *workspacev1.EditFileRequest) (*workspacev1.EditFileResponse, error) {
res := s.Toolbox.EditFile(req.GetPath(), req.GetOldString(), req.GetNewString(), req.GetReplaceAll())
if res.IsError {
return &workspacev1.EditFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.EditFileResponse{DiffPreview: res.Content}, nil
}
func (s *Server) MultiEditFile(ctx context.Context, req *workspacev1.MultiEditFileRequest) (*workspacev1.MultiEditFileResponse, error) {
edits := make([]sandboxfs.WorkspaceEdit, 0, len(req.GetEdits()))
for _, e := range req.GetEdits() {
edits = append(edits, sandboxfs.WorkspaceEdit{
OldString: e.GetOldString(), NewString: e.GetNewString(), ReplaceAll: e.GetReplaceAll(),
})
}
res := s.Toolbox.MultiEditFile(req.GetPath(), edits)
if res.IsError {
return &workspacev1.MultiEditFileResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.MultiEditFileResponse{DiffPreview: res.Content}, nil
}
func (s *Server) ListDirectory(ctx context.Context, req *workspacev1.ListDirectoryRequest) (*workspacev1.ListDirectoryResponse, error) {
res := s.Toolbox.ListDirectory(req.GetPath())
if res.IsError {
return &workspacev1.ListDirectoryResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.ListDirectoryResponse{Listing: res.Content}, nil
}
func (s *Server) GlobSearch(ctx context.Context, req *workspacev1.GlobSearchRequest) (*workspacev1.GlobSearchResponse, error) {
res := s.Toolbox.GlobSearch(req.GetPattern(), req.GetPath())
if res.IsError {
return &workspacev1.GlobSearchResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.GlobSearchResponse{Output: res.Content}, nil
}
func (s *Server) Grep(ctx context.Context, req *workspacev1.GrepRequest) (*workspacev1.GrepResponse, error) {
res := s.Toolbox.Grep(req.GetPattern(), req.GetPath(), req.GetGlob(), req.GetCaseInsensitive())
if res.IsError {
return &workspacev1.GrepResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.GrepResponse{Output: res.Content}, nil
}
// RunBashCommand replaces the run_bash_command tool. req.TimeoutSeconds
// is accepted but not applied per-call — the monolith's sandbox only
// ever used one process-wide timeout (ENKLY_BASH_TIMEOUT_SECONDS /
// Toolbox.CommandTimeout, set once at startup; see cmd/server/main.go),
// and a per-call override wasn't a feature of the original tool, so
// introducing one here would be a behavior change, not a neutral
// restructuring.
func (s *Server) RunBashCommand(ctx context.Context, req *workspacev1.RunBashCommandRequest) (*workspacev1.RunBashCommandResponse, error) {
res := s.Toolbox.RunBashCommand(ctx, req.GetCommand())
if res.IsError {
return &workspacev1.RunBashCommandResponse{IsError: true, ErrorMessage: res.Content}, nil
}
return &workspacev1.RunBashCommandResponse{Output: res.Content}, nil
}