turbo-editors/turbo-corepublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

process.go · 167 lines · 4.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 18h ago1package acp
2
3import (
4 "bufio"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "sync"
11)
12
13// MaxLogLines is how much of an agent's standard error is kept for the status
14// dialog. It is where a misconfigured model endpoint reports itself, and it is
15// also where a chatty agent would otherwise grow without bound.
16const MaxLogLines = 200
17
18// process is a running agent: the child, its pipes as one stream, and whatever
19// it has said on standard error.
20type process struct {
21 command *exec.Cmd
22 stream io.ReadWriteCloser
23
24 mu sync.Mutex
25 log []string
26 over int // lines dropped past the cap
27}
28
29// pipes joins a child's stdin and stdout into the single stream jsonrpc wants.
30//
31// Closing it closes the input, which is how an agent is told to finish: every
32// well-behaved one exits on end of input, so the process ends without a signal.
33type pipes struct {
34 io.ReadCloser
35 io.WriteCloser
36}
37
38// Close shuts both halves, reporting the first failure.
39func (p pipes) Close() error {
40 writeErr := p.WriteCloser.Close()
41 readErr := p.ReadCloser.Close()
42 if writeErr != nil {
43 return writeErr
44 }
45 return readErr
46}
47
48// startProcess runs an agent and returns it, with its pipes joined into one
49// stream and its standard error being drained.
50//
51// The child's environment is the editor's with the agent's own added on top, so
52// a credential already in the environment reaches the agent without being
53// written into a file.
54func startProcess(agent Agent, projectDir string, onLog func(string)) (*process, error) {
55 command := exec.Command(agent.Command, agent.Args...)
56 command.Dir = workingDirectory(agent, projectDir)
57 command.Env = environment(agent)
58
59 stdin, err := command.StdinPipe()
60 if err != nil {
61 return nil, fmt.Errorf("acp: connecting to %s: %w", agent.Command, err)
62 }
63 stdout, err := command.StdoutPipe()
64 if err != nil {
65 return nil, fmt.Errorf("acp: connecting to %s: %w", agent.Command, err)
66 }
67 stderr, err := command.StderrPipe()
68 if err != nil {
69 return nil, fmt.Errorf("acp: connecting to %s: %w", agent.Command, err)
70 }
71
72 if err := command.Start(); err != nil {
73 return nil, fmt.Errorf("acp: starting %s: %w", agent.CommandLine(), err)
74 }
75
76 p := &process{command: command, stream: pipes{ReadCloser: stdout, WriteCloser: stdin}}
77 go p.drain(stderr, onLog)
78 return p, nil
79}
80
81// workingDirectory returns where the agent should run: its own cwd if it named
82// one, and the project otherwise.
83func workingDirectory(agent Agent, projectDir string) string {
84 if agent.Cwd == "" {
85 return projectDir
86 }
87 if filepath.IsAbs(agent.Cwd) {
88 return agent.Cwd
89 }
90 return filepath.Join(projectDir, agent.Cwd)
91}
92
93// environment returns the child's environment: the editor's, plus the agent's.
94func environment(agent Agent) []string {
95 if len(agent.Env) == 0 {
96 return nil // nil means "inherit", which is what os/exec does with it
97 }
98
99 env := os.Environ()
100 for name, value := range agent.Env {
101 env = append(env, name+"="+value)
102 }
103 return env
104}
105
106// drain reads the agent's standard error, keeping the last lines of it.
107//
108// It must be read rather than ignored: a full pipe blocks the child, and an
109// agent that logs enough would stop answering with no error anywhere. It is
110// also not protocol — `docker agent` prints a welcome banner here — so nothing
111// read from it ever reaches the JSON-RPC layer.
112func (p *process) drain(stderr io.Reader, onLog func(string)) {
113 scanner := bufio.NewScanner(stderr)
114 scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
115
116 for scanner.Scan() {
117 line := scanner.Text()
118 p.record(line)
119 if onLog != nil {
120 onLog(line)
121 }
122 }
123}
124
125// record keeps one line of the log, dropping the oldest past the cap.
126func (p *process) record(line string) {
127 p.mu.Lock()
128 defer p.mu.Unlock()
129
130 p.log = append(p.log, line)
131 if len(p.log) > MaxLogLines {
132 p.log = p.log[len(p.log)-MaxLogLines:]
133 p.over++
134 }
135}
136
137// Log returns what the agent has said on standard error, most recent last.
138func (p *process) Log() []string {
139 p.mu.Lock()
140 defer p.mu.Unlock()
141
142 out := make([]string, len(p.log))
143 copy(out, p.log)
144 return out
145}
146
147// Dropped returns how many log lines were lost to the cap.
148func (p *process) Dropped() int {
149 p.mu.Lock()
150 defer p.mu.Unlock()
151 return p.over
152}
153
154// Stop ends the agent.
155//
156// Closing the stream closes its standard input, which is how the protocol says
157// to finish: an agent reading end of input exits. The process is then killed
158// anyway, because an agent that ignores it must not outlive the window — and
159// Wait is what stops it becoming a zombie.
160func (p *process) Stop() error {
161 err := p.stream.Close()
162 if p.command.Process != nil {
163 _ = p.command.Process.Kill()
164 }
165 _ = p.command.Wait()
166 return err
167}