610 lines
22 KiB
Text
610 lines
22 KiB
Text
작업이 <!-- task=cli_terminal_cycle plan=0 tag=API -->
|
|
|
|
# Claude CLI 터미널 원격 제어 1-cycle
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
각 항목의 체크리스트를 완료하면 즉시 체크 표시한다. 중간/최종 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다. 계획과 다르게 구현해야 하는 부분이 생기면 이유를 `CODE_REVIEW.md`의 `계획 대비 변경 사항`에 남기고, 모든 구현 항목의 실제 동작과 수동 검증 결과를 기록한다.
|
|
|
|
## 배경
|
|
|
|
현재 edge 콘솔과 node CLI는 TCP/protobuf로 `RunRequest`를 보내고 `RunEvent`를 받는 한 사이클을 이미 갖추고 있다. 하지만 `cli` adapter는 요청마다 `exec.CommandContext`를 새로 실행하고 prompt를 argv 끝에 붙이는 구조라, node 시작 직후 Claude 터미널 프로세스를 띄운 뒤 edge 입력을 stdin으로 전달하는 목표와 맞지 않는다. 이번 작업은 기존 hexagonal 경계를 유지하면서 `cli/claude` 프로필 하나로 원격 입력 -> node 터미널 프로세스 -> edge 출력까지 수동 검증 가능한 1-cycle을 완성한다.
|
|
|
|
## 의존 관계 및 구현 순서
|
|
|
|
1. [API-1]에서 설정/edge payload/node payload 파싱 계약을 먼저 확장한다.
|
|
2. [API-2]에서 확장된 CLI profile 값을 사용하는 persistent terminal process를 구현한다.
|
|
3. [API-3]에서 node bootstrap이 persistent adapter lifecycle을 시작/정리하도록 연결한다.
|
|
4. [API-4]에서 edge 콘솔 기본값과 문서를 Claude CLI 수동 테스트 흐름에 맞춘다.
|
|
|
|
---
|
|
|
|
### [API-1] CLI profile 설정 계약에 persistent terminal 옵션 추가
|
|
|
|
#### 문제
|
|
|
|
`packages/config/config.go:95-99`:
|
|
```go
|
|
type CLIProfileConf struct {
|
|
Command string `mapstructure:"command" yaml:"command"`
|
|
Args []string `mapstructure:"args" yaml:"args"`
|
|
Env []string `mapstructure:"env" yaml:"env"`
|
|
}
|
|
```
|
|
|
|
CLI profile에는 command/args/env만 있어 프로세스를 node 시작 시 띄울지, 터미널(PTY)로 실행할지, 응답을 어떤 기준으로 한 RunEvent cycle로 자를지 표현할 수 없다.
|
|
|
|
`apps/edge/internal/transport/server.go:190-199`:
|
|
```go
|
|
if rec.Adapters.CLI.Enabled {
|
|
profiles := make(map[string]any)
|
|
for name, p := range rec.Adapters.CLI.Profiles {
|
|
profiles[name] = map[string]any{
|
|
"command": p.Command,
|
|
"args": stringsToAny(p.Args),
|
|
"env": stringsToAny(p.Env),
|
|
}
|
|
}
|
|
if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil {
|
|
```
|
|
|
|
Edge가 node에 내려주는 CLI payload도 동일하게 command/args/env만 전달한다.
|
|
|
|
`apps/node/internal/adapters/factory.go:81-99`:
|
|
```go
|
|
prof := config.CLIProfileConf{}
|
|
if cmd, ok := p["command"].(string); ok {
|
|
prof.Command = cmd
|
|
}
|
|
if args, ok := p["args"].([]any); ok {
|
|
for _, a := range args {
|
|
if s, ok := a.(string); ok {
|
|
prof.Args = append(prof.Args, s)
|
|
}
|
|
}
|
|
}
|
|
if envs, ok := p["env"].([]any); ok {
|
|
for _, e := range envs {
|
|
if s, ok := e.(string); ok {
|
|
prof.Env = append(prof.Env, s)
|
|
```
|
|
|
|
Node payload parser도 새 profile 동작을 받을 수 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
`config.CLIProfileConf`에 아래 필드를 추가한다. `persistent=false`가 기본값이므로 기존 one-shot CLI adapter 동작은 유지된다.
|
|
|
|
```go
|
|
type CLIProfileConf struct {
|
|
Command string `mapstructure:"command" yaml:"command"`
|
|
Args []string `mapstructure:"args" yaml:"args"`
|
|
Env []string `mapstructure:"env" yaml:"env"`
|
|
Persistent bool `mapstructure:"persistent" yaml:"persistent"`
|
|
Terminal bool `mapstructure:"terminal" yaml:"terminal"`
|
|
ResponseIdleTimeoutMS int `mapstructure:"response_idle_timeout_ms" yaml:"response_idle_timeout_ms"`
|
|
StartupIdleTimeoutMS int `mapstructure:"startup_idle_timeout_ms" yaml:"startup_idle_timeout_ms"`
|
|
}
|
|
```
|
|
|
|
`buildConfigPayload`는 CLI profile map에 새 필드를 그대로 포함한다.
|
|
|
|
```go
|
|
profiles[name] = map[string]any{
|
|
"command": p.Command,
|
|
"args": stringsToAny(p.Args),
|
|
"env": stringsToAny(p.Env),
|
|
"persistent": p.Persistent,
|
|
"terminal": p.Terminal,
|
|
"response_idle_timeout_ms": p.ResponseIdleTimeoutMS,
|
|
"startup_idle_timeout_ms": p.StartupIdleTimeoutMS,
|
|
}
|
|
```
|
|
|
|
`cliConfFromStruct`는 `structpb.Struct.AsMap()`의 number가 `float64`로 들어오는 점을 고려해 bool/int 변환 helper를 둔다.
|
|
|
|
```go
|
|
if persistent, ok := boolFromAny(p["persistent"]); ok {
|
|
prof.Persistent = persistent
|
|
}
|
|
if terminal, ok := boolFromAny(p["terminal"]); ok {
|
|
prof.Terminal = terminal
|
|
}
|
|
if ms, ok := intFromAny(p["response_idle_timeout_ms"]); ok {
|
|
prof.ResponseIdleTimeoutMS = ms
|
|
}
|
|
if ms, ok := intFromAny(p["startup_idle_timeout_ms"]); ok {
|
|
prof.StartupIdleTimeoutMS = ms
|
|
}
|
|
```
|
|
|
|
`configs/edge.yaml:14-34`는 원격 CLI 테스트용 기본값으로 전환한다.
|
|
|
|
```yaml
|
|
console:
|
|
adapter: "cli"
|
|
model: "claude"
|
|
|
|
nodes:
|
|
- alias: "local-node"
|
|
token: "changeme"
|
|
adapters:
|
|
cli:
|
|
enabled: true
|
|
profiles:
|
|
claude:
|
|
command: "claude"
|
|
args: []
|
|
env: []
|
|
persistent: true
|
|
terminal: true
|
|
response_idle_timeout_ms: 1500
|
|
startup_idle_timeout_ms: 300
|
|
```
|
|
|
|
이번 항목은 rename/removal이 없으므로 호출부 갱신 대상은 없다. 값 추가에 따른 변경 call site는 `apps/edge/internal/transport/server.go:190-199`, `apps/node/internal/adapters/factory.go:81-99`, `configs/edge.yaml:14-34`이다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `packages/config/config.go` - `CLIProfileConf` 새 필드 추가
|
|
- [ ] `apps/edge/internal/transport/server.go` - `buildConfigPayload` CLI profile payload에 persistent/terminal/timeout 필드 추가
|
|
- [ ] `apps/edge/internal/transport/server_test.go` - CLI payload roundtrip 테스트에 새 필드 assertion 추가
|
|
- [ ] `apps/node/internal/adapters/factory.go` - `cliConfFromStruct`에서 bool/int 필드 파싱 추가
|
|
- [ ] `apps/node/internal/adapters/factory_internal_test.go` - `cliConfFromStruct` 내부 테스트 추가
|
|
- [ ] `configs/edge.yaml` - console target과 local-node CLI profile을 `cli/claude` persistent terminal로 변경
|
|
|
|
#### 테스트 작성
|
|
|
|
작성:
|
|
|
|
- `apps/edge/internal/transport/server_test.go`의 `TestBuildConfigPayload_CLIArgsRoundtrip`를 확장해 `persistent`, `terminal`, `response_idle_timeout_ms`, `startup_idle_timeout_ms`가 `AdapterConfig.Settings`에 보존되는지 검증한다.
|
|
- `apps/node/internal/adapters/factory_internal_test.go`를 `package adapters`로 추가하고 `TestCLIConfFromStruct_ProfileRuntimeOptions`를 작성한다. `structpb.NewStruct`로 만든 CLI settings에서 bool/int 값이 `config.CLIProfileConf`로 정상 파싱되는지 확인한다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/edge/internal/transport ./apps/node/internal/adapters
|
|
```
|
|
|
|
기대 결과: edge payload roundtrip과 node CLI config parsing 테스트가 통과한다.
|
|
|
|
---
|
|
|
|
### [API-2] CLI adapter에 node-start persistent terminal process 구현
|
|
|
|
#### 문제
|
|
|
|
`apps/node/internal/adapters/cli/cli.go:57-65`:
|
|
```go
|
|
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
|
profile, ok := c.profiles[spec.Model]
|
|
if !ok {
|
|
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
|
|
}
|
|
|
|
prompt := extractPrompt(spec.Input)
|
|
args := append(append([]string{}, profile.Args...), prompt)
|
|
cmd := exec.CommandContext(ctx, profile.Command, args...)
|
|
```
|
|
|
|
현재 구현은 edge 입력을 CLI 프로세스의 stdin으로 전달하지 않고 argv 끝에 prompt를 추가한다.
|
|
|
|
`apps/node/internal/adapters/cli/cli.go:72-112`:
|
|
```go
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("cli adapter: stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("cli adapter: stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("cli adapter: start %q: %w", profile.Command, err)
|
|
}
|
|
...
|
|
if err := cmd.Wait(); err != nil {
|
|
```
|
|
|
|
요청마다 새 프로세스를 만들고 프로세스 종료를 기다린다. Claude 같은 interactive terminal CLI를 node 시작 시 한 번 띄우고 여러 edge 입력에 재사용하는 구조가 아니다.
|
|
|
|
`apps/node/internal/adapters/cli/cli.go:122-131`:
|
|
```go
|
|
return sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: spec.RunID,
|
|
Type: runtime.EventTypeComplete,
|
|
Message: "cli execution complete",
|
|
```
|
|
|
|
complete 이벤트가 프로세스 종료에 묶여 있어 persistent process에서는 응답 경계를 만들 수 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
`github.com/creack/pty`를 사용해 `terminal=true` profile을 PTY로 실행한다. `terminal=false`인 persistent profile은 stdin/stdout pipe를 사용해 테스트 가능성을 확보한다. 새 import는 아래 형태를 기준으로 한다.
|
|
|
|
```go
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/creack/pty"
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/config"
|
|
)
|
|
```
|
|
|
|
`CLI`에 persistent session map을 둔다.
|
|
|
|
```go
|
|
type CLI struct {
|
|
profiles map[string]config.CLIProfileConf
|
|
sessions map[string]*profileSession
|
|
logger *zap.Logger
|
|
}
|
|
|
|
type profileSession struct {
|
|
name string
|
|
profile config.CLIProfileConf
|
|
cmd *exec.Cmd
|
|
input io.Writer
|
|
output <-chan cliOutput
|
|
done <-chan error
|
|
closeFn func() error
|
|
mu sync.Mutex
|
|
}
|
|
```
|
|
|
|
`Start(ctx)`는 `Persistent=true` profile을 node bootstrap 시점에 시작한다. `Terminal=true`면 `pty.Start(cmd)`로 PTY를 열고, 아니면 `cmd.StdinPipe`/`cmd.StdoutPipe`를 사용한다. startup banner는 `StartupIdleTimeoutMS` 동안 drain해서 첫 RunRequest 응답에 섞이지 않도록 한다. command가 없거나 start 실패 시 error를 반환해 node 시작을 실패시키고 설정 오류를 빨리 드러낸다.
|
|
|
|
```go
|
|
func (c *CLI) Start(ctx context.Context) error {
|
|
for name, profile := range c.profiles {
|
|
if !profile.Persistent {
|
|
continue
|
|
}
|
|
sess, err := startProfileSession(ctx, name, profile, c.logger)
|
|
if err != nil {
|
|
return fmt.Errorf("cli adapter: start profile %q: %w", name, err)
|
|
}
|
|
c.sessions[name] = sess
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
`Execute`는 `Persistent=false`면 기존 one-shot 로직을 `executeOneShot`로 유지하고, `Persistent=true`면 profile session을 찾아 mutex로 한 요청씩 직렬화한다. prompt는 `input`에 `prompt + "\n"`으로 write하고, output reader goroutine이 보낸 chunk를 `delta`로 emit한다. 응답 경계는 첫 output 이후 `ResponseIdleTimeoutMS` 동안 새 output이 없으면 complete로 처리한다.
|
|
|
|
```go
|
|
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
|
profile, ok := c.profiles[spec.Model]
|
|
if !ok {
|
|
return fmt.Errorf("cli adapter: unknown profile %q", spec.Model)
|
|
}
|
|
if profile.Persistent {
|
|
return c.executePersistent(ctx, spec, profile, sink)
|
|
}
|
|
return c.executeOneShot(ctx, spec, profile, sink)
|
|
}
|
|
```
|
|
|
|
`Stop(ctx)`는 모든 session에 대해 closeFn을 실행하고 프로세스가 남아 있으면 kill한다. context 취소, 프로세스 종료, reader error는 `RuntimeEvent{Type: error}`로 edge에 전달한다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `go.mod` / `go.sum` - `go get github.com/creack/pty` 후 `go mod tidy`
|
|
- [ ] `apps/node/internal/adapters/cli/cli.go` - one-shot 실행을 `executeOneShot`로 분리
|
|
- [ ] `apps/node/internal/adapters/cli/cli.go` - persistent profile session, PTY/stdin pipe 시작, output reader, startup drain 구현
|
|
- [ ] `apps/node/internal/adapters/cli/cli.go` - persistent `Execute`에서 edge prompt를 stdin/PTY로 write하고 idle timeout 기준으로 delta/complete emit
|
|
- [ ] `apps/node/internal/adapters/cli/cli.go` - `Start(ctx)`/`Stop(ctx)` lifecycle method 구현
|
|
- [ ] `apps/node/internal/adapters/cli/cli_test.go` - one-shot 회귀 테스트와 persistent terminal echo 테스트 추가
|
|
|
|
#### 테스트 작성
|
|
|
|
작성:
|
|
|
|
- `apps/node/internal/adapters/cli/cli_test.go` - `TestCLIExecuteOneShotPassesPromptAsArg`: `sh -c 'printf "reply:%s\n" "$1"' sh` 또는 동등한 작은 명령으로 기존 one-shot prompt argv 동작이 유지되는지 검증한다.
|
|
- `apps/node/internal/adapters/cli/cli_test.go` - `TestCLIStartPersistentTerminalAndExecuteWritesPrompt`: `sh -c 'stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done'`를 `Persistent=true`, `Terminal=true` profile로 시작하고 `Execute`가 prompt를 write해 `reply:<prompt>` delta를 받는지 검증한다. 테스트는 Unix shell/PTY가 필요한 경우 `runtime.GOOS == "windows"`에서 skip한다.
|
|
- fake sink는 emitted events를 slice에 모아 `start`, `delta`, `complete` 순서와 delta 내용 포함 여부를 확인한다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/node/internal/adapters/cli
|
|
```
|
|
|
|
기대 결과: one-shot CLI 실행과 persistent terminal 실행 테스트가 모두 통과한다.
|
|
|
|
---
|
|
|
|
### [API-3] Node bootstrap에서 adapter lifecycle 시작/정리 연결
|
|
|
|
#### 문제
|
|
|
|
`apps/node/internal/bootstrap/module.go:43-63`:
|
|
```go
|
|
reg, err := adapters.BuildFromPayload(result.Config, logger)
|
|
if err != nil {
|
|
_ = result.Session.Close()
|
|
return fmt.Errorf("bootstrap: build adapters: %w", err)
|
|
}
|
|
...
|
|
rtr := router.New(reg, logger)
|
|
n := node.New(result.NodeID, rtr, reg, st, logger)
|
|
result.Session.SetHandler(n)
|
|
sess = result.Session
|
|
```
|
|
|
|
Node는 edge 등록 후 adapter registry를 만들지만 adapter lifecycle을 시작하지 않는다. 따라서 CLI adapter에 `Start(ctx)`를 추가해도 node 시작 직후 Claude 프로세스가 실행되지 않는다.
|
|
|
|
`apps/node/internal/adapters/registry.go:40-47`:
|
|
```go
|
|
// All returns all registered adapters in registration order.
|
|
func (r *Registry) All() []runtime.Adapter {
|
|
out := make([]runtime.Adapter, 0, len(r.order))
|
|
for _, name := range r.order {
|
|
out = append(out, r.adapters[name])
|
|
}
|
|
return out
|
|
}
|
|
```
|
|
|
|
Registry는 adapter 열거만 제공하고 lifecycle adapter를 시작/정리하는 공통 진입점이 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
`apps/node/internal/adapters/registry.go`에 optional lifecycle interface와 registry method를 추가한다. CLI package는 이 interface를 import하지 않아도 method set으로 만족한다.
|
|
|
|
```go
|
|
type LifecycleAdapter interface {
|
|
Start(ctx context.Context) error
|
|
Stop(ctx context.Context) error
|
|
}
|
|
|
|
func (r *Registry) Start(ctx context.Context) error {
|
|
for _, adapter := range r.All() {
|
|
lifecycle, ok := adapter.(LifecycleAdapter)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := lifecycle.Start(ctx); err != nil {
|
|
return fmt.Errorf("adapter %q start: %w", adapter.Name(), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Registry) Stop(ctx context.Context) error {
|
|
for i := len(r.order) - 1; i >= 0; i-- {
|
|
adapter := r.adapters[r.order[i]]
|
|
lifecycle, ok := adapter.(LifecycleAdapter)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := lifecycle.Stop(ctx); err != nil {
|
|
return fmt.Errorf("adapter %q stop: %w", adapter.Name(), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
필요한 import 추가:
|
|
|
|
```go
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"iop/apps/node/internal/runtime"
|
|
)
|
|
```
|
|
|
|
`apps/node/internal/bootstrap/module.go`에서는 `reg`를 hook 바깥 변수로 보관하고, store 생성 후 handler attach 전에 `reg.Start(ctx)`를 호출한다. 시작 실패 시 session/store를 닫고 error를 반환한다. `OnStop`에서는 session/store 정리 전에 `reg.Stop(context.Background())`를 호출한다.
|
|
|
|
```go
|
|
var sess *transport.Session
|
|
var st *store.Store
|
|
var reg *adapters.Registry
|
|
...
|
|
reg, err = adapters.BuildFromPayload(result.Config, logger)
|
|
...
|
|
if err := reg.Start(ctx); err != nil {
|
|
_ = result.Session.Close()
|
|
_ = st.Close()
|
|
return fmt.Errorf("bootstrap: start adapters: %w", err)
|
|
}
|
|
...
|
|
if reg != nil {
|
|
if err := reg.Stop(context.Background()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `apps/node/internal/adapters/registry.go` - optional lifecycle interface와 `Start`/`Stop` methods 추가
|
|
- [ ] `apps/node/internal/adapters/registry_test.go` - lifecycle adapter start/stop 호출 테스트 추가
|
|
- [ ] `apps/node/internal/bootstrap/module.go` - registry lifecycle을 node startup/shutdown hook에 연결
|
|
|
|
#### 테스트 작성
|
|
|
|
작성:
|
|
|
|
- `apps/node/internal/adapters/registry_test.go` - fake adapter가 `Start`/`Stop` method를 구현하게 하고 registry `Start(ctx)`/`Stop(ctx)`가 lifecycle adapter만 호출하며 stop이 등록 역순으로 호출되는지 검증한다.
|
|
|
|
`bootstrap.Module` 자체는 현재 fx integration test가 없으므로 새 테스트를 추가하지 않는다. 대신 `go test ./apps/node/...`로 compile coverage를 확보하고, 최종 수동 검증에서 node 시작 직후 `claude` process start 로그/동작을 확인한다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/node/internal/adapters ./apps/node/...
|
|
```
|
|
|
|
기대 결과: registry lifecycle 테스트와 node 전체 package 테스트가 통과한다.
|
|
|
|
---
|
|
|
|
### [API-4] Edge 콘솔 수동 테스트를 `cli/claude` 1-cycle에 맞게 정리
|
|
|
|
#### 문제
|
|
|
|
`apps/edge/cmd/edge/console.go:111-120`:
|
|
```go
|
|
req := &iop.RunRequest{
|
|
RunId: runID,
|
|
Adapter: adapter,
|
|
Model: model,
|
|
Input: input,
|
|
TimeoutSec: 30,
|
|
Metadata: map[string]string{
|
|
```
|
|
|
|
Edge 콘솔의 `RunRequest.TimeoutSec`가 30초로 고정되어 있어 Claude CLI 응답이 느린 환경에서 수동 테스트가 불안정할 수 있다.
|
|
|
|
`apps/edge/cmd/edge/console.go:128-137`:
|
|
```go
|
|
timer := time.NewTimer(35 * time.Second)
|
|
defer timer.Stop()
|
|
...
|
|
case <-timer.C:
|
|
return fmt.Errorf("timed out waiting for node response")
|
|
```
|
|
|
|
Edge-side wait timeout도 고정값이다.
|
|
|
|
`apps/edge/README.md:11-35`와 `apps/node/README.md:46-52`는 수동 테스트가 mock adapter 기준이라고 설명한다. `configs/edge.yaml:14-34`도 console target이 `mock/mock-echo`이고 CLI adapter가 disabled라, 현재 문서와 config만으로는 Claude CLI 1-cycle을 실행할 수 없다.
|
|
|
|
#### 해결 방법
|
|
|
|
`EdgeConsoleConf`에 timeout 설정을 추가하고 기본값을 120초로 둔다.
|
|
|
|
```go
|
|
type EdgeConsoleConf struct {
|
|
Adapter string `mapstructure:"adapter" yaml:"adapter"`
|
|
Model string `mapstructure:"model" yaml:"model"`
|
|
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec"`
|
|
}
|
|
```
|
|
|
|
`setEdgeDefaults`에 기본값을 추가한다.
|
|
|
|
```go
|
|
v.SetDefault("console.timeout_sec", 120)
|
|
```
|
|
|
|
`sendConsoleRun` 호출부와 시그니처를 바꾸어 config timeout을 사용한다.
|
|
|
|
Before (`apps/edge/cmd/edge/console.go:81`):
|
|
```go
|
|
if err := sendConsoleRun(ctx, registry, events, out, cfg.Console.Adapter, cfg.Console.Model, message); err != nil {
|
|
```
|
|
|
|
After:
|
|
```go
|
|
if err := sendConsoleRun(ctx, registry, events, out, cfg.Console.Adapter, cfg.Console.Model, cfg.Console.TimeoutSec, message); err != nil {
|
|
```
|
|
|
|
`sendConsoleRun` 내부는 0 이하 값을 30초 fallback으로 보정하고 `RunRequest.TimeoutSec`와 edge wait timer에 함께 적용한다.
|
|
|
|
```go
|
|
if timeoutSec <= 0 {
|
|
timeoutSec = 30
|
|
}
|
|
...
|
|
TimeoutSec: int32(timeoutSec),
|
|
...
|
|
timer := time.NewTimer(time.Duration(timeoutSec+5) * time.Second)
|
|
```
|
|
|
|
`apps/edge/README.md`와 `apps/node/README.md`는 `bin/edge.sh`/`bin/node.sh`가 `cli/claude` persistent terminal profile을 사용하는 수동 검증이라는 점, node host에 `claude` 명령이 PATH에 있어야 한다는 점, `edge>` 입력이 node의 Claude process stdin으로 전달된다는 점을 반영한다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `packages/config/config.go` - `EdgeConsoleConf.TimeoutSec` 필드와 기본값 추가
|
|
- [ ] `packages/config/config_test.go` - edge console timeout default/load 테스트 추가
|
|
- [ ] `apps/edge/cmd/edge/console.go` - console timeout을 `RunRequest.TimeoutSec`와 response timer에 사용
|
|
- [ ] `configs/edge.yaml` - `console.timeout_sec` 값 추가
|
|
- [ ] `apps/edge/README.md` - 원격 Claude CLI 수동 테스트 절차와 예상 출력 갱신
|
|
- [ ] `apps/node/README.md` - node 측 Claude CLI persistent process 동작 설명 갱신
|
|
|
|
#### 테스트 작성
|
|
|
|
작성:
|
|
|
|
- `packages/config/config_test.go` - 최소 edge YAML을 임시 파일로 만들고 `LoadEdge`가 `console.timeout_sec` 기본값을 채우는지 확인한다. 별도 YAML에 `timeout_sec: 45`를 넣어 override가 반영되는지도 확인한다.
|
|
|
|
스킵:
|
|
|
|
- `apps/edge/cmd/edge/console.go`의 실제 console network loop 테스트는 현재 `edgenode.NodeEntry.Client`가 concrete `*toki.TcpClient`라 fake 주입이 어렵다. 이번 항목에서는 config 테스트와 package compile, 그리고 최종 수동 검증으로 커버한다.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./packages/config ./apps/edge/cmd/edge
|
|
```
|
|
|
|
기대 결과: console timeout config 테스트와 edge CLI package compile이 통과한다.
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 경로 | 작업 항목 |
|
|
|-----------|-----------|
|
|
| `go.mod` | [API-2] |
|
|
| `go.sum` | [API-2] |
|
|
| `packages/config/config.go` | [API-1], [API-4] |
|
|
| `packages/config/config_test.go` | [API-4] |
|
|
| `configs/edge.yaml` | [API-1], [API-4] |
|
|
| `apps/edge/internal/transport/server.go` | [API-1] |
|
|
| `apps/edge/internal/transport/server_test.go` | [API-1] |
|
|
| `apps/edge/cmd/edge/console.go` | [API-4] |
|
|
| `apps/edge/README.md` | [API-4] |
|
|
| `apps/node/README.md` | [API-4] |
|
|
| `apps/node/internal/adapters/factory.go` | [API-1] |
|
|
| `apps/node/internal/adapters/factory_internal_test.go` | [API-1] |
|
|
| `apps/node/internal/adapters/registry.go` | [API-3] |
|
|
| `apps/node/internal/adapters/registry_test.go` | [API-3] |
|
|
| `apps/node/internal/adapters/cli/cli.go` | [API-2] |
|
|
| `apps/node/internal/adapters/cli/cli_test.go` | [API-2] |
|
|
| `apps/node/internal/bootstrap/module.go` | [API-3] |
|
|
|
|
## 최종 검증
|
|
|
|
```bash
|
|
go mod tidy
|
|
go test ./...
|
|
```
|
|
|
|
기대 결과: 모든 Go package 테스트가 통과하고 `go.mod`/`go.sum`이 정리된 상태를 유지한다.
|
|
|
|
수동 검증(Claude CLI가 node host의 PATH에 있어야 함):
|
|
|
|
```bash
|
|
# edge host
|
|
./bin/edge.sh
|
|
|
|
# node host
|
|
./bin/node.sh
|
|
|
|
# edge console
|
|
/nodes
|
|
hello
|
|
```
|
|
|
|
기대 결과:
|
|
|
|
- node 시작 직후 `cli` adapter가 `claude` persistent terminal process를 시작한다.
|
|
- edge console의 `hello` 입력이 `RunRequest{adapter=cli, model=claude}`로 node에 전달된다.
|
|
- node 콘솔에는 `[edge-message] hello`와 `[node-event] start/complete`가 표시된다.
|
|
- edge 콘솔에는 `[node-local-node-event] start`, 하나 이상의 delta 누적 결과, `[node-local-node-event] complete`, `[node-local-node-message] ...`가 표시된다.
|