Files
Amir Raminfar 8811dc82bd
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
fix(cloud): resolve read-only container tools in one shot (no extra LLM round-trip) (#4767)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:59:19 -07:00

110 lines
2.8 KiB
Go

package cloud
import (
"context"
"encoding/json"
"fmt"
"regexp"
"time"
"github.com/amir20/dozzle/internal/container"
pb "github.com/amir20/dozzle/proto/cloud"
)
type fetchLogsArgs struct {
ContainerID string `json:"container_id"`
Host string `json:"host_id"`
Start string `json:"start"`
End string `json:"end"`
Level string `json:"level"`
Query string `json:"query"`
Regex string `json:"regex"`
Inverse bool `json:"inverse"`
}
func executeFetchContainerLogs(ctx context.Context, argsJSON string, deps ToolDeps) (*pb.CallToolResponse, error) {
var args fetchLogsArgs
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return nil, fmt.Errorf("failed to parse arguments: %w", err)
}
hostID, containerID, note, err := resolveContainerRefRead(args.ContainerID, args.Host, deps)
if err != nil {
return nil, err
}
cs, err := deps.HostService.FindContainer(hostID, containerID, deps.Labels)
if err != nil {
return nil, fmt.Errorf("container not found: %w", err)
}
start := time.Now().Add(-1 * time.Hour)
end := time.Now()
if args.Start != "" {
t, err := time.Parse(time.RFC3339, args.Start)
if err != nil {
return nil, fmt.Errorf("invalid start time format (expected RFC3339): %w", err)
}
start = t
}
if args.End != "" {
t, err := time.Parse(time.RFC3339, args.End)
if err != nil {
return nil, fmt.Errorf("invalid end time format (expected RFC3339): %w", err)
}
end = t
}
var re *regexp.Regexp
if args.Regex != "" {
var err error
re, err = regexp.Compile(args.Regex)
if err != nil {
return nil, fmt.Errorf("invalid regex pattern: %w", err)
}
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
logCh, err := cs.LogsBetweenDates(ctx, start, end, container.STDOUT|container.STDERR)
if err != nil {
return nil, fmt.Errorf("failed to fetch logs: %w", err)
}
const maxLines = 100
entries := make([]*pb.LogEntry, 0, maxLines)
for event := range logCh {
msg, matches := matchesFilters(event, &args, re)
if !matches {
continue
}
entries = append(entries, &pb.LogEntry{
Timestamp: event.Timestamp,
Message: msg,
Stream: event.Stream,
Level: event.Level,
})
if len(entries) >= maxLines {
break
}
}
return &pb.CallToolResponse{
Success: true,
Result: &pb.CallToolResponse_FetchLogs{FetchLogs: &pb.FetchLogsResult{ContainerName: withResolutionNote(cs.Container.Name, note), Entries: entries}},
}, nil
}
// withResolutionNote appends the read-path resolution note (if any) to a
// model-visible identity string, so the model learns which container an
// ambiguous name resolved to — and that siblings exist — in the same turn,
// without a round-trip. Empty note returns name unchanged.
func withResolutionNote(name, note string) string {
if note == "" {
return name
}
return name + " " + note
}