Skip to content

Commit f9f69ce

Browse files
worstellstuartwdouglasgithub-actions[bot]
authored
console: add Goose panel (#5108)
https://github.com/user-attachments/assets/938c2046-c18e-4364-bbaf-4cd25305e795 --------- Co-authored-by: Stuart Douglas <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 8a5f077 commit f9f69ce

File tree

18 files changed

+1240
-430
lines changed

18 files changed

+1240
-430
lines changed

backend/console/console.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package console
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"net/url"
89
"time"
@@ -11,6 +12,7 @@ import (
1112

1213
ftlversion "github.com/block/ftl"
1314
"github.com/block/ftl/backend/admin"
15+
"github.com/block/ftl/backend/goose"
1416
adminpb "github.com/block/ftl/backend/protos/xyz/block/ftl/admin/v1"
1517
buildenginepb "github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1"
1618
"github.com/block/ftl/backend/protos/xyz/block/ftl/buildengine/v1/buildenginepbconnect"
@@ -629,3 +631,36 @@ func (s *service) GetInfo(ctx context.Context, _ *connect.Request[consolepb.GetI
629631
BuildTime: ftlversion.Timestamp.Format(time.RFC3339),
630632
}), nil
631633
}
634+
635+
func (s *service) ExecuteGoose(ctx context.Context, req *connect.Request[consolepb.ExecuteGooseRequest], stream *connect.ServerStream[consolepb.ExecuteGooseResponse]) error {
636+
if req.Msg.Prompt == "" {
637+
return connect.NewError(connect.CodeInvalidArgument, errors.New("prompt cannot be empty"))
638+
}
639+
640+
logger := log.FromContext(ctx).Scope("console")
641+
client := goose.NewClient()
642+
643+
err := client.Execute(ctx, req.Msg.Prompt, func(msg goose.Message) {
644+
var source consolepb.ExecuteGooseResponse_Source
645+
switch msg.Source {
646+
case goose.SourceStdout:
647+
source = consolepb.ExecuteGooseResponse_SOURCE_STDOUT
648+
case goose.SourceStderr:
649+
source = consolepb.ExecuteGooseResponse_SOURCE_STDERR
650+
case goose.SourceCompletion:
651+
source = consolepb.ExecuteGooseResponse_SOURCE_COMPLETION
652+
}
653+
654+
err := stream.Send(&consolepb.ExecuteGooseResponse{
655+
Response: msg.Content,
656+
Source: source,
657+
})
658+
if err != nil {
659+
logger.Debugf("failed to send response: %v", err)
660+
}
661+
})
662+
if err != nil {
663+
return fmt.Errorf("failed to execute goose: %w", err)
664+
}
665+
return nil
666+
}

backend/goose/goose.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package goose
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec" //nolint:depguard
10+
"regexp"
11+
"strings"
12+
"sync"
13+
14+
"github.com/block/ftl/internal"
15+
"github.com/block/ftl/internal/log"
16+
)
17+
18+
// MessageSource represents the source of a message from Goose
19+
type MessageSource int
20+
21+
const (
22+
// SourceStdout represents messages from standard output
23+
SourceStdout MessageSource = iota
24+
// SourceStderr represents messages from standard error
25+
SourceStderr
26+
// SourceCompletion represents a completion signal
27+
SourceCompletion
28+
)
29+
30+
type Message struct {
31+
Content string
32+
Source MessageSource
33+
}
34+
35+
type Client struct {
36+
}
37+
38+
func NewClient() *Client {
39+
return &Client{}
40+
}
41+
42+
// Execute runs a Goose command with the given prompt and streams cleaned messages
43+
// to the provided callback function. The callback is called for each cleaned message
44+
// and when execution completes.
45+
//
46+
// For now this uses the Goose CLI via FTL CLI to execute the command, but we should switch to
47+
// native Goose API calls when available.
48+
func (c *Client) Execute(ctx context.Context, prompt string, callback func(Message)) error {
49+
if prompt == "" {
50+
return fmt.Errorf("prompt cannot be empty")
51+
}
52+
53+
logger := log.FromContext(ctx).Scope("goose")
54+
55+
stdoutReader, stdoutWriter := io.Pipe()
56+
stderrReader, stderrWriter := io.Pipe()
57+
58+
// Only close writers in defer as cleanup in case of early returns
59+
defer func() {
60+
if stdoutWriter != nil {
61+
stdoutWriter.Close()
62+
}
63+
if stderrWriter != nil {
64+
stderrWriter.Close()
65+
}
66+
}()
67+
68+
gitRoot, ok := internal.GitRoot(os.Getenv("FTL_DIR")).Get()
69+
if !ok {
70+
return fmt.Errorf("failed to find Git root")
71+
}
72+
73+
cmd := exec.CommandContext(ctx, "ftl", "goose", prompt)
74+
cmd.Dir = gitRoot
75+
cmd.Stdout = stdoutWriter
76+
cmd.Stderr = stderrWriter
77+
78+
scanOutput := func(reader *io.PipeReader, source MessageSource) error {
79+
defer reader.Close()
80+
scanner := bufio.NewScanner(reader)
81+
scanner.Buffer(make([]byte, 4096), 1024*1024)
82+
for scanner.Scan() {
83+
line := scanner.Text()
84+
if shouldFilterLine(line) {
85+
continue
86+
}
87+
if cleaned := cleanMessage(line); cleaned != "" {
88+
callback(Message{
89+
Content: cleaned,
90+
Source: source,
91+
})
92+
}
93+
}
94+
return scanner.Err()
95+
}
96+
97+
var wg sync.WaitGroup
98+
wg.Add(2)
99+
100+
if err := cmd.Start(); err != nil {
101+
return fmt.Errorf("failed to start goose command: %w", err)
102+
}
103+
104+
go func() {
105+
defer wg.Done()
106+
if err := scanOutput(stdoutReader, SourceStdout); err != nil {
107+
logger.Warnf("Error reading stdout: %v", err)
108+
}
109+
}()
110+
go func() {
111+
defer wg.Done()
112+
if err := scanOutput(stderrReader, SourceStderr); err != nil {
113+
logger.Warnf("Error reading stderr: %v", err)
114+
}
115+
}()
116+
117+
err := cmd.Wait()
118+
119+
// Close writers to signal EOF to readers
120+
stdoutWriter.Close()
121+
stderrWriter.Close()
122+
// Set to nil so defer doesn't try to close again
123+
stdoutWriter = nil
124+
stderrWriter = nil
125+
126+
wg.Wait()
127+
if err != nil {
128+
return fmt.Errorf("failed to execute goose command: %w", err)
129+
}
130+
131+
callback(Message{
132+
Content: "",
133+
Source: SourceCompletion,
134+
})
135+
return nil
136+
}
137+
138+
// shouldFilterLine returns true if the line should be filtered out
139+
func shouldFilterLine(line string) bool {
140+
// Filter out session management lines and other typical output that should be removed
141+
return strings.HasPrefix(line, "Closing session.") ||
142+
strings.Contains(line, "resuming session") ||
143+
strings.Contains(line, "Session:") ||
144+
strings.Contains(line, "working directory:") ||
145+
strings.Contains(line, "logging to")
146+
}
147+
148+
// cleanMessage cleans a message from Goose by:
149+
// - Removing ANSI escape sequences
150+
// - Removing tool output headers and separators
151+
// - Cleaning up paths and repeated info
152+
// - Preserving important line breaks while removing excessive ones
153+
// - Handling markdown elements
154+
// - Removing duplicate sections while preserving structure
155+
func cleanMessage(s string) string {
156+
s = stripAnsiCodes(s)
157+
158+
toolPattern := `─+\s*(Status|Timeline|Read|Write|NewModule|CallVerb|ResetSubscription|NewMySQLDatabase|NewMySQLMigration|NewPostgresDatabase|NewPostgresMigration|SubscriptionInfo)\s*\|\s*\w+\s*─+`
159+
hashPattern := `###[^#]+###`
160+
pathPattern := `(?:path|content|verificationToken): \.{3}`
161+
readPattern := `Read contents of [^\n]+\n`
162+
newlinePattern := `\n{3,}`
163+
trimPattern := `^\n+|\n+$`
164+
s = regexp.MustCompile(toolPattern).ReplaceAllString(s, "")
165+
s = regexp.MustCompile(hashPattern).ReplaceAllString(s, "")
166+
s = regexp.MustCompile(pathPattern).ReplaceAllString(s, "")
167+
s = regexp.MustCompile(readPattern).ReplaceAllString(s, "")
168+
s = regexp.MustCompile(newlinePattern).ReplaceAllString(s, "\n\n")
169+
s = regexp.MustCompile(trimPattern).ReplaceAllString(s, "")
170+
171+
result := strings.Join(splitIntoBlocks(s), "\n\n")
172+
result = regexp.MustCompile(`\n{3,}`).ReplaceAllString(result, "\n\n")
173+
return strings.TrimSpace(result)
174+
}
175+
176+
// splitIntoBlocks splits content into logical blocks while preserving formatting
177+
func splitIntoBlocks(s string) []string {
178+
var blocks []string
179+
var currentBlock []string
180+
181+
lines := strings.Split(s, "\n")
182+
183+
for _, line := range lines {
184+
trimmed := strings.TrimSpace(line)
185+
186+
// Start a new block if:
187+
// 1. Current line is empty and we have content in currentBlock
188+
// 2. Current line starts a new markdown block
189+
// 3. Current line starts a new list item and previous wasn't a list
190+
if len(currentBlock) > 0 && (trimmed == "" ||
191+
isMarkdownBlockStart(trimmed) ||
192+
(isListItem(trimmed) && !isListItem(strings.TrimSpace(currentBlock[len(currentBlock)-1])))) {
193+
194+
if blockContent := strings.TrimSpace(strings.Join(currentBlock, "\n")); blockContent != "" {
195+
blocks = append(blocks, blockContent)
196+
}
197+
currentBlock = nil
198+
199+
// Skip empty lines between blocks
200+
if trimmed == "" {
201+
continue
202+
}
203+
}
204+
205+
currentBlock = append(currentBlock, line)
206+
}
207+
208+
if len(currentBlock) > 0 {
209+
if blockContent := strings.TrimSpace(strings.Join(currentBlock, "\n")); blockContent != "" {
210+
blocks = append(blocks, blockContent)
211+
}
212+
}
213+
214+
return blocks
215+
}
216+
217+
// isMarkdownBlockStart checks if a line starts a new markdown block
218+
func isMarkdownBlockStart(line string) bool {
219+
return strings.HasPrefix(line, "#") || // Headers
220+
strings.HasPrefix(line, "```") || // Code blocks
221+
strings.HasPrefix(line, ">") || // Blockquotes
222+
strings.HasPrefix(line, "- ") || // Unordered lists
223+
strings.HasPrefix(line, "* ") || // Alternative unordered lists
224+
regexp.MustCompile(`^\d+\.\s`).MatchString(line) // Ordered lists
225+
}
226+
227+
// isListItem checks if a line is a list item
228+
func isListItem(line string) bool {
229+
return strings.HasPrefix(line, "- ") ||
230+
strings.HasPrefix(line, "* ") ||
231+
regexp.MustCompile(`^\d+\.\s`).MatchString(line)
232+
}
233+
234+
// stripAnsiCodes removes ANSI escape sequences from a string
235+
func stripAnsiCodes(s string) string {
236+
ansiColorPattern := `\x1b\[[0-9;]*[mK]`
237+
ansiOSCPattern := `\x1b\][0-9];.*?\x1b\\`
238+
ansiOSCHyperlinkPattern := `\x1b\]8;;.*?\x1b\\`
239+
240+
result := regexp.MustCompile(ansiColorPattern).ReplaceAllString(s, "")
241+
result = regexp.MustCompile(ansiOSCPattern).ReplaceAllString(result, "")
242+
result = regexp.MustCompile(ansiOSCHyperlinkPattern).ReplaceAllString(result, "")
243+
result = strings.ReplaceAll(result, "0m", "")
244+
result = strings.TrimRight(result, " \t\n\r")
245+
246+
return result
247+
}

0 commit comments

Comments
 (0)