feat(node/cli): improve persistent session handling and Claude terminal support
- Add writePrompt with character-by-character typing for terminal profiles (2ms delay) - Separate cliOutput into text and markerLine fields for precise completion matching - Add consumeCompleteLines for buffered output parsing - Add matchAny for multi-line completion marker checking - Auto-accept Claude Code Bypass Permissions warning at startup - Change PTY output reader from bufio.Scanner to ptmx.Read for raw byte output - Improve drainUntilIdle with profile info and Claude bypass warning handling - Add Claude status raw output fallback when usage parsing fails - Add comprehensive blackbox tests for persistent sessions (21 test cases) - Update e2e smoke test with proper wait functions, mock CLI script, and timeout variables - Update edge.yaml console target from gemini to claude-tui, increase timeouts - Update web app: add dev documentation, verify script, engines field, test message
This commit is contained in:
parent
ed882cf6a4
commit
1479949db0
17 changed files with 669 additions and 40 deletions
|
|
@ -27,13 +27,14 @@ import (
|
|||
const Name = "cli"
|
||||
|
||||
const (
|
||||
modeCodexExec = "codex-exec"
|
||||
modeOpencodeSSE = "opencode-sse"
|
||||
modePersistentLazy = "persistent-lazy"
|
||||
modeCodexExec = "codex-exec"
|
||||
modeOpencodeSSE = "opencode-sse"
|
||||
modePersistentLazy = "persistent-lazy"
|
||||
)
|
||||
|
||||
type cliOutput struct {
|
||||
line string
|
||||
text string
|
||||
markerLine string
|
||||
}
|
||||
|
||||
// sessionKey uniquely identifies a logical worker session.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -17,6 +18,8 @@ import (
|
|||
"iop/packages/config"
|
||||
)
|
||||
|
||||
const terminalInputDelay = 2 * time.Millisecond
|
||||
|
||||
func emitRuntimeError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
|
||||
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: runID,
|
||||
|
|
@ -81,7 +84,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
if _, err := fmt.Fprintf(sess.input, "%s\n", prompt); err != nil {
|
||||
if err := writePrompt(ctx, sess.input, prompt, profile); err != nil {
|
||||
return emitRuntimeError(ctx, sink, spec.RunID, fmt.Sprintf("write prompt: %v", err))
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +96,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
}
|
||||
}()
|
||||
outputTokens := 0
|
||||
var markerBuf strings.Builder
|
||||
|
||||
for {
|
||||
select {
|
||||
|
|
@ -116,14 +120,20 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
c.mu.Unlock()
|
||||
return emitRuntimeError(ctx, sink, spec.RunID, "persistent session process exited unexpectedly")
|
||||
}
|
||||
outputTokens += len(strings.Fields(out.line))
|
||||
outputTokens += len(strings.Fields(out.text))
|
||||
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeDelta,
|
||||
Delta: out.line + "\n",
|
||||
Delta: out.text,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
if matcher.match(out.line) {
|
||||
markerLines := []string(nil)
|
||||
if out.markerLine != "" {
|
||||
markerLines = append(markerLines, out.markerLine)
|
||||
} else {
|
||||
markerLines = consumeCompleteLines(&markerBuf, out.text)
|
||||
}
|
||||
if matcher.matchAny(markerLines) {
|
||||
return sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeComplete,
|
||||
|
|
@ -173,6 +183,68 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
}
|
||||
}
|
||||
|
||||
func (m completionMatcher) matchAny(lines []string) bool {
|
||||
for _, line := range lines {
|
||||
if m.match(line) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func consumeCompleteLines(buf *strings.Builder, text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
buf.WriteString(text)
|
||||
raw := buf.String()
|
||||
start := 0
|
||||
var lines []string
|
||||
for i, r := range raw {
|
||||
if r != '\n' {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, strings.TrimRight(raw[start:i], "\r"))
|
||||
start = i + 1
|
||||
}
|
||||
if start > 0 {
|
||||
buf.Reset()
|
||||
buf.WriteString(raw[start:])
|
||||
} else if len(raw) > 8192 {
|
||||
buf.Reset()
|
||||
buf.WriteString(raw[len(raw)-8192:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func promptTerminator(profile config.CLIProfileConf) string {
|
||||
if profile.Terminal {
|
||||
return "\r"
|
||||
}
|
||||
return "\n"
|
||||
}
|
||||
|
||||
func writePrompt(ctx context.Context, input io.Writer, prompt string, profile config.CLIProfileConf) error {
|
||||
if !profile.Terminal {
|
||||
_, err := io.WriteString(input, prompt+promptTerminator(profile))
|
||||
return err
|
||||
}
|
||||
for _, r := range prompt {
|
||||
if _, err := io.WriteString(input, string(r)); err != nil {
|
||||
return err
|
||||
}
|
||||
timer := time.NewTimer(terminalInputDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
_, err := io.WriteString(input, promptTerminator(profile))
|
||||
return err
|
||||
}
|
||||
|
||||
// resolveSession returns an existing session for the given key or creates one
|
||||
// when SessionMode allows it.
|
||||
func (c *CLI) resolveSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*profileSession, error) {
|
||||
|
|
@ -219,13 +291,18 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
input = ptmx
|
||||
closeFn = ptmx.Close
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(ptmx)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
outputCh <- cliOutput{line: line}
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if n > 0 {
|
||||
outputCh <- cliOutput{text: string(buf[:n])}
|
||||
}
|
||||
if err != nil {
|
||||
doneCh <- cmd.Wait()
|
||||
close(outputCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
doneCh <- cmd.Wait()
|
||||
close(outputCh)
|
||||
}()
|
||||
} else {
|
||||
cmd = exec.Command(profile.Command, profile.Args...)
|
||||
|
|
@ -250,7 +327,8 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
outputCh <- cliOutput{line: scanner.Text()}
|
||||
line := scanner.Text()
|
||||
outputCh <- cliOutput{text: line + "\n", markerLine: line}
|
||||
}
|
||||
doneCh <- cmd.Wait()
|
||||
close(outputCh)
|
||||
|
|
@ -258,7 +336,7 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
}
|
||||
|
||||
if profile.StartupIdleTimeoutMS > 0 {
|
||||
drainUntilIdle(outputCh, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key.target)
|
||||
drainUntilIdle(outputCh, input, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key, profile)
|
||||
}
|
||||
|
||||
select {
|
||||
|
|
@ -286,15 +364,28 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
}, nil
|
||||
}
|
||||
|
||||
func drainUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, name string) {
|
||||
func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Duration, logger *zap.Logger, key sessionKey, profile config.CLIProfileConf) {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
var startupBuf strings.Builder
|
||||
acceptedClaudeBypassWarning := false
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-outputCh:
|
||||
case out, ok := <-outputCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if profile.Terminal && !acceptedClaudeBypassWarning && out.text != "" {
|
||||
appendBounded(&startupBuf, out.text, 8192)
|
||||
if shouldAcceptClaudeBypassWarning(startupBuf.String()) {
|
||||
if _, err := io.WriteString(input, "\x1b[B\r"); err != nil {
|
||||
logger.Warn("cli adapter: accept claude bypass warning", zap.String("target", key.target), zap.Error(err))
|
||||
} else {
|
||||
acceptedClaudeBypassWarning = true
|
||||
logger.Info("cli adapter: accepted claude bypass warning", zap.String("target", key.target))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
|
|
@ -302,13 +393,50 @@ func drainUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *za
|
|||
}
|
||||
}
|
||||
timer.Reset(timeout)
|
||||
logger.Debug("cli adapter: startup drain", zap.String("profile", name))
|
||||
logger.Debug("cli adapter: startup drain", zap.String("target", key.target), zap.String("session", key.sessionID))
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendBounded(buf *strings.Builder, s string, max int) {
|
||||
buf.WriteString(s)
|
||||
raw := buf.String()
|
||||
if len(raw) <= max {
|
||||
return
|
||||
}
|
||||
buf.Reset()
|
||||
buf.WriteString(raw[len(raw)-max:])
|
||||
}
|
||||
|
||||
func shouldAcceptClaudeBypassWarning(raw string) bool {
|
||||
compact := compactTerminalText(raw)
|
||||
return strings.Contains(compact, "claudecoderunninginbypasspermissionsmode") &&
|
||||
strings.Contains(compact, "yesiaccept")
|
||||
}
|
||||
|
||||
func compactTerminalText(s string) string {
|
||||
var b strings.Builder
|
||||
inEscape := false
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inEscape = true
|
||||
continue
|
||||
}
|
||||
if inEscape {
|
||||
if r >= '@' && r <= '~' {
|
||||
inEscape = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, key sessionKey) {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ package cli_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
osexec "os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -164,6 +167,224 @@ func TestCLIStartPersistentTerminalAndExecuteWritesPrompt(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCLIExecutePersistentTerminalSendsCarriageReturn(t *testing.T) {
|
||||
testutil.RequirePTYSupport(t)
|
||||
if _, err := osexec.LookPath("stty"); err != nil {
|
||||
t.Skip("stty required")
|
||||
}
|
||||
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"raw-tui": {
|
||||
Command: os.Args[0],
|
||||
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
|
||||
Env: []string{"IOP_RAW_TUI_HELPER=1"},
|
||||
Persistent: true,
|
||||
Terminal: true,
|
||||
ResponseIdleTimeoutMS: 100,
|
||||
StartupIdleTimeoutMS: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
|
||||
ctx := context.Background()
|
||||
if err := c.Start(ctx); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Stop(ctx) }()
|
||||
|
||||
execCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sink := &testutil.FakeSink{}
|
||||
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
||||
RunID: "run-raw-tui",
|
||||
Target: "raw-tui",
|
||||
Input: map[string]any{"prompt": "hello"},
|
||||
}, sink)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
events := sink.Events()
|
||||
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") {
|
||||
t.Fatalf("expected reply:hello in deltas, got %q", combined)
|
||||
}
|
||||
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete {
|
||||
t.Fatalf("expected complete event, got %s", last.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) {
|
||||
testutil.RequirePTYSupport(t)
|
||||
if _, err := osexec.LookPath("stty"); err != nil {
|
||||
t.Skip("stty required")
|
||||
}
|
||||
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"claude-warning": {
|
||||
Command: os.Args[0],
|
||||
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
|
||||
Env: []string{"IOP_CLAUDE_WARNING_HELPER=1"},
|
||||
Persistent: true,
|
||||
Terminal: true,
|
||||
ResponseIdleTimeoutMS: 100,
|
||||
StartupIdleTimeoutMS: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
|
||||
ctx := context.Background()
|
||||
if err := c.Start(ctx); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Stop(ctx) }()
|
||||
|
||||
execCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sink := &testutil.FakeSink{}
|
||||
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
||||
RunID: "run-claude-warning",
|
||||
Target: "claude-warning",
|
||||
Input: map[string]any{"prompt": "hello"},
|
||||
}, sink)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "reply:hello") {
|
||||
t.Fatalf("expected reply:hello in deltas, got %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawTUIHelperProcess(t *testing.T) {
|
||||
switch {
|
||||
case os.Getenv("IOP_RAW_TUI_HELPER") == "1":
|
||||
runRawTUIHelper()
|
||||
case os.Getenv("IOP_CLAUDE_WARNING_HELPER") == "1":
|
||||
runClaudeWarningHelper()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setRawTerminal() {
|
||||
cmd := osexec.Command("stty", "raw", "-echo")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "stty raw: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func runRawTUIHelper() {
|
||||
setRawTerminal()
|
||||
readPromptAndReply()
|
||||
}
|
||||
|
||||
func runClaudeWarningHelper() {
|
||||
setRawTerminal()
|
||||
fmt.Fprint(os.Stdout, "WARNING: Claude Code running in BypassPermissions mode\r\n2. Yes, I accept\r\n")
|
||||
_ = os.Stdout.Sync()
|
||||
|
||||
want := []byte{'\x1b', '[', 'B', '\r'}
|
||||
seen := make([]byte, 0, len(want))
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf)
|
||||
if n > 0 {
|
||||
seen = append(seen, buf[0])
|
||||
if len(seen) > len(want) {
|
||||
seen = seen[len(seen)-len(want):]
|
||||
}
|
||||
if string(seen) == string(want) {
|
||||
fmt.Fprint(os.Stdout, "ready\r\n")
|
||||
_ = os.Stdout.Sync()
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
readPromptAndReply()
|
||||
}
|
||||
|
||||
func readPromptAndReply() {
|
||||
var prompt []byte
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf)
|
||||
if n > 0 {
|
||||
if buf[0] == '\r' {
|
||||
fmt.Fprintf(os.Stdout, "reply:%s\n", string(prompt))
|
||||
_ = os.Stdout.Sync()
|
||||
prompt = prompt[:0]
|
||||
} else {
|
||||
prompt = append(prompt, buf[0])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIStartPersistentTerminalEmitsRawChunksWithoutNewline(t *testing.T) {
|
||||
testutil.RequirePTYSupport(t)
|
||||
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"raw-terminal": {
|
||||
Command: "sh",
|
||||
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "chunk:%s" "$line"; done`},
|
||||
Persistent: true,
|
||||
Terminal: true,
|
||||
ResponseIdleTimeoutMS: 100,
|
||||
StartupIdleTimeoutMS: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
|
||||
ctx := context.Background()
|
||||
if err := c.Start(ctx); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Stop(ctx) }()
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sink := &testutil.FakeSink{}
|
||||
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
|
||||
RunID: "run-raw",
|
||||
Target: "raw-terminal",
|
||||
Input: map[string]any{"prompt": "hello"},
|
||||
}, sink)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
events := sink.Events()
|
||||
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "chunk:hello") {
|
||||
t.Fatalf("expected raw chunk in deltas, got %q", combined)
|
||||
}
|
||||
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete {
|
||||
t.Fatalf("expected complete event, got %s", last.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCLIExecutePersistentCancelDoesNotTerminateSession verifies that a
|
||||
// cancelled run leaves the session alive so subsequent runs can reuse it.
|
||||
func TestCLIExecutePersistentCancelDoesNotTerminateSession(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@ func (c *ClaudeChecker) Check(ctx context.Context) (*UsageStatus, error) {
|
|||
}
|
||||
}
|
||||
if err := fullUsageWait(60 * time.Second); err != nil {
|
||||
if st, parseErr := ParseStatusOutput(fullOutput); parseErr == nil && st != nil && strings.TrimSpace(st.RawOutput) != "" {
|
||||
return st, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed waiting for usage output: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -125,6 +126,48 @@ exit 0
|
|||
}
|
||||
}
|
||||
|
||||
func TestClaudeCheckerReturnsRawFallbackWhenUsageHasNoLimitBlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fakeClaude := filepath.Join(dir, "claude")
|
||||
|
||||
script := `#!/usr/bin/env sh
|
||||
printf 'Claude Code v2.1.142\n'
|
||||
printf '? for shortcuts\n'
|
||||
sleep 0.1
|
||||
|
||||
IFS= read -r command
|
||||
cmd="${command#?}"
|
||||
if [ "$cmd" != "/usage" ] && [ "$command" != "/usage" ]; then
|
||||
printf 'unexpected command: %%s\n' "$command"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
printf 'Last 24h · these are independent characteristics of your usage, not a breakdown\n'
|
||||
printf 'Nothing over 10%% in this period — try the other window.\n'
|
||||
printf 'd to day · w to week\n'
|
||||
printf 'Esc to cancel\n'
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake claude: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
checker := NewClaudeChecker(fakeClaude)
|
||||
status, err := checker.Check(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Check failed: %v", err)
|
||||
}
|
||||
if status.DailyLimit != "" || status.WeeklyLimit != "" {
|
||||
t.Fatalf("expected no parsed limits, got daily=%q weekly=%q", status.DailyLimit, status.WeeklyLimit)
|
||||
}
|
||||
if !strings.Contains(status.RawOutput, "Nothing over 10%") {
|
||||
t.Fatalf("expected raw fallback output, got %q", status.RawOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeCheckerSelectsUsageAndParsesRepaintedScreen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fakeClaude := filepath.Join(dir, "claude")
|
||||
|
|
|
|||
|
|
@ -6,14 +6,61 @@ Control Plane과의 주요 통신은 edge-node에서 사용 중인 protobuf-sock
|
|||
|
||||
## Local
|
||||
|
||||
웹 개발과 테스트는 기본적으로 호스트 환경에서 수행한다. Docker는 컨테이너 패키징과 compose 통합 확인이 필요할 때 사용한다.
|
||||
|
||||
필요한 런타임:
|
||||
|
||||
- Node.js `>=20.9.0`
|
||||
- npm `>=10`
|
||||
- Go toolchain
|
||||
|
||||
루트에서 Control Plane을 먼저 실행한다.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
go run ./apps/control-plane/cmd/control-plane serve --config configs/control-plane.yaml
|
||||
```
|
||||
|
||||
다른 터미널에서 Web Portal을 실행한다.
|
||||
|
||||
```bash
|
||||
npm ci --prefix apps/web
|
||||
./bin/web.sh
|
||||
```
|
||||
|
||||
기본 호스트 endpoint는 코드 기본값과 `configs/control-plane.yaml` 기준을 따른다.
|
||||
|
||||
```bash
|
||||
CONTROL_PLANE_INTERNAL_HTTP_URL=http://localhost:9080
|
||||
NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL=http://localhost:9080
|
||||
NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL=tcp://localhost:19080
|
||||
```
|
||||
|
||||
호스트 검증은 다음 명령을 기준으로 한다.
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run verify
|
||||
```
|
||||
|
||||
현재 `npm run verify`는 TypeScript check와 production build를 수행한다. Next.js 16 기준 lint는 `next lint`가 아니라 ESLint CLI(`eslint .`)를 사용한다. 아직 이 앱에는 ESLint 의존성, 설정 파일, `lint` script가 없으므로 lint는 필수 검증에 포함하지 않는다.
|
||||
|
||||
lint를 도입할 때는 다음 기준을 따른다.
|
||||
|
||||
- `eslint`, `eslint-config-next`와 flat config(`eslint.config.*`)를 함께 추가한다.
|
||||
- `package.json`에는 `"lint": "eslint ."`를 추가한다.
|
||||
- `verify`는 `npm run check && npm run lint && npm run build` 순서로 갱신한다.
|
||||
|
||||
production standalone 실행을 확인할 때는 먼저 build를 만든 뒤 실행한다.
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
npm run build
|
||||
PORT=3000 HOSTNAME=0.0.0.0 npm run start
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
루트에서 실행한다.
|
||||
Docker는 compose 기반 통합 확인이 필요할 때 루트에서 실행한다.
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
|
|
|
|||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
4
apps/web/package-lock.json
generated
4
apps/web/package-lock.json
generated
|
|
@ -7,6 +7,10 @@
|
|||
"": {
|
||||
"name": "@iop/web",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=20.9.0",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@
|
|||
"name": "@iop/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "npm@10.8.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "node .next/standalone/server.js",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"verify": "npm run check && npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export default async function Home() {
|
|||
<p className="text-lg leading-8 text-muted-foreground">
|
||||
IOP 전체 Web Portal을 위한 최소 Next.js 스캐폴드입니다. 세부 도메인 화면은 아직 고정하지 않습니다.
|
||||
</p>
|
||||
<p className="text-sm font-medium text-emerald-600">웹 테스트 화면이 정상 렌더링되었습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
BIN
bin/control-plane
Executable file
BIN
bin/control-plane
Executable file
Binary file not shown.
BIN
bin/edge
Executable file
BIN
bin/edge
Executable file
Binary file not shown.
BIN
bin/node
Executable file
BIN
bin/node
Executable file
Binary file not shown.
28
bin/web.sh
Executable file
28
bin/web.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
WEB_DIR="$REPO_ROOT/apps/web"
|
||||
|
||||
WEB_HOST="${IOP_WEB_HOST:-0.0.0.0}"
|
||||
WEB_PORT="${IOP_WEB_PORT:-3000}"
|
||||
|
||||
export CONTROL_PLANE_INTERNAL_HTTP_URL="${CONTROL_PLANE_INTERNAL_HTTP_URL:-http://localhost:9080}"
|
||||
export NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL="${NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL:-http://localhost:9080}"
|
||||
export NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL="${NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL:-tcp://localhost:19080}"
|
||||
|
||||
if [[ ! -d "$WEB_DIR/node_modules" ]]; then
|
||||
echo "[web] missing dependencies in $WEB_DIR/node_modules" >&2
|
||||
echo "[web] run: cd apps/web && npm ci" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$WEB_DIR"
|
||||
echo "[web] dir=$WEB_DIR"
|
||||
echo "[web] listen=$WEB_HOST:$WEB_PORT"
|
||||
echo "[web] control_plane_internal_http=$CONTROL_PLANE_INTERNAL_HTTP_URL"
|
||||
echo "[web] control_plane_public_http=$NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL"
|
||||
echo "[web] control_plane_wire=$NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL"
|
||||
|
||||
exec npm run dev -- --hostname "$WEB_HOST" --port "$WEB_PORT"
|
||||
BIN
bin/worker
Executable file
BIN
bin/worker
Executable file
Binary file not shown.
|
|
@ -17,7 +17,7 @@ metrics:
|
|||
|
||||
console:
|
||||
adapter: "cli"
|
||||
target: "gemini"
|
||||
target: "claude-tui"
|
||||
session_id: "default"
|
||||
background: false
|
||||
timeout_sec: 300
|
||||
|
|
@ -59,8 +59,8 @@ nodes:
|
|||
- "TERM=xterm-256color"
|
||||
persistent: true
|
||||
terminal: true
|
||||
response_idle_timeout_ms: 3000
|
||||
startup_idle_timeout_ms: 1500
|
||||
response_idle_timeout_ms: 8000
|
||||
startup_idle_timeout_ms: 4000
|
||||
mode: "persistent-lazy"
|
||||
gemini:
|
||||
command: "gemini"
|
||||
|
|
|
|||
|
|
@ -22,13 +22,37 @@ EDGE_METRICS_PORT=$((40000 + RANDOM % 10000))
|
|||
NODE_METRICS_PORT=$((50000 + RANDOM % 10000))
|
||||
PROFILE="${IOP_E2E_PROFILE:-mock}"
|
||||
IDLE_SECONDS="${IOP_E2E_IDLE_SECONDS:-0}"
|
||||
REGISTER_TIMEOUT="${IOP_E2E_REGISTER_TIMEOUT:-60}"
|
||||
EVENT_TIMEOUT="${IOP_E2E_EVENT_TIMEOUT:-30}"
|
||||
RUN_TIMEOUT="${IOP_E2E_RUN_TIMEOUT:-120}"
|
||||
STATUS_TIMEOUT="${IOP_E2E_STATUS_TIMEOUT:-$RUN_TIMEOUT}"
|
||||
COMMAND_SETTLE_SECONDS="${IOP_E2E_COMMAND_SETTLE_SECONDS:-0.2}"
|
||||
IS_PERSISTENT=0
|
||||
HAS_STATUS=0
|
||||
FIRST_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_alpha"
|
||||
FIRST_EXPECTED="SMOKE_ALPHA"
|
||||
SECOND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_beta"
|
||||
SECOND_EXPECTED="SMOKE_BETA"
|
||||
BACKGROUND_PROMPT="Convert this token to uppercase and reply with only the converted token: iop_smoke_bg"
|
||||
BACKGROUND_EXPECTED="SMOKE_BG"
|
||||
|
||||
if [ "$PROFILE" = "mock" ]; then
|
||||
echo "[e2e] preparing honest mock smoke test (using cli adapter + cat)..."
|
||||
echo "[e2e] preparing honest mock smoke test (using scripted cli adapter)..."
|
||||
IS_PERSISTENT=1
|
||||
TARGET="fake-cat"
|
||||
TARGET="fake-cli"
|
||||
MOCK_CLI="$TMP_DIR/fake-cli.sh"
|
||||
cat <<'EOF' > "$MOCK_CLI"
|
||||
#!/usr/bin/env sh
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*iop_smoke_alpha*) printf 'IOP_SMOKE_ALPHA\n' ;;
|
||||
*iop_smoke_beta*) printf 'IOP_SMOKE_BETA\n' ;;
|
||||
*iop_smoke_bg*) printf 'IOP_SMOKE_BG\n' ;;
|
||||
*) printf 'IOP_SMOKE_UNKNOWN\n' ;;
|
||||
esac
|
||||
done
|
||||
EOF
|
||||
chmod +x "$MOCK_CLI"
|
||||
cat <<EOF > "$EDGE_CONFIG"
|
||||
server:
|
||||
listen: "127.0.0.1:$PORT"
|
||||
|
|
@ -46,13 +70,13 @@ nodes:
|
|||
cli:
|
||||
enabled: true
|
||||
profiles:
|
||||
fake-cat:
|
||||
command: "cat"
|
||||
fake-cli:
|
||||
command: "$MOCK_CLI"
|
||||
persistent: true
|
||||
response_idle_timeout_ms: 1000
|
||||
console:
|
||||
adapter: cli
|
||||
target: fake-cat
|
||||
target: fake-cli
|
||||
session_id: default
|
||||
EOF
|
||||
else
|
||||
|
|
@ -149,32 +173,147 @@ done
|
|||
|
||||
IOP_NODE_CONFIG="$NODE_CONFIG" "$REPO_ROOT/bin/node.sh" > "$NODE_OUT" 2>&1 &
|
||||
NODE_PID=$!
|
||||
LAST_CMD_START_LINE=1
|
||||
|
||||
send_cmd() {
|
||||
echo "[e2e] > $1"
|
||||
echo "$1" >&3
|
||||
sleep 3
|
||||
ensure_smoke_processes_running() {
|
||||
local context="$1"
|
||||
|
||||
if ! kill -0 "$EDGE_PID" 2>/dev/null; then
|
||||
echo "[e2e] edge exited while waiting for $context"
|
||||
echo "=== EDGE OUTPUT ==="
|
||||
cat "$EDGE_OUT"
|
||||
exit 1
|
||||
fi
|
||||
if ! kill -0 "$NODE_PID" 2>/dev/null; then
|
||||
echo "[e2e] node exited while waiting for $context"
|
||||
echo "=== NODE OUTPUT ==="
|
||||
cat "$NODE_OUT"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
STRICT_PROMPT="Reply with exactly 'OK' and do not inspect files or use any tools."
|
||||
wait_for_node_registration() {
|
||||
local deadline
|
||||
deadline=$((SECONDS + REGISTER_TIMEOUT))
|
||||
|
||||
sleep 3
|
||||
echo "[e2e] waiting for node registration (timeout: ${REGISTER_TIMEOUT}s)"
|
||||
while ! grep -q '\[node-test-node-event\] connected reason="registered"' "$EDGE_OUT"; do
|
||||
ensure_smoke_processes_running "node registration"
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "[e2e] node registration timed out"
|
||||
echo "=== EDGE OUTPUT ==="
|
||||
cat "$EDGE_OUT"
|
||||
echo "=== NODE OUTPUT ==="
|
||||
cat "$NODE_OUT"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_edge_pattern_since() {
|
||||
local pattern="$1"
|
||||
local start_line="$2"
|
||||
local msg="$3"
|
||||
local timeout="${4:-$EVENT_TIMEOUT}"
|
||||
local deadline
|
||||
local slice
|
||||
|
||||
deadline=$((SECONDS + timeout))
|
||||
slice="$TMP_DIR/edge_wait_slice"
|
||||
|
||||
while true; do
|
||||
tail -n +"$start_line" "$EDGE_OUT" > "$slice" || true
|
||||
if grep -qE "$pattern" "$slice"; then
|
||||
return 0
|
||||
fi
|
||||
ensure_smoke_processes_running "$msg"
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "[e2e] FAIL: timed out waiting for $msg (pattern: '$pattern', timeout: ${timeout}s)"
|
||||
echo "=== EDGE OUTPUT ==="
|
||||
cat "$EDGE_OUT"
|
||||
echo "=== NODE OUTPUT ==="
|
||||
cat "$NODE_OUT"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_edge_text_since() {
|
||||
local text="$1"
|
||||
local start_line="$2"
|
||||
local msg="$3"
|
||||
local timeout="${4:-$EVENT_TIMEOUT}"
|
||||
local deadline
|
||||
local slice
|
||||
|
||||
deadline=$((SECONDS + timeout))
|
||||
slice="$TMP_DIR/edge_wait_slice"
|
||||
|
||||
while true; do
|
||||
tail -n +"$start_line" "$EDGE_OUT" > "$slice" || true
|
||||
if grep -qF "$text" "$slice"; then
|
||||
return 0
|
||||
fi
|
||||
ensure_smoke_processes_running "$msg"
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "[e2e] FAIL: timed out waiting for $msg (text: '$text', timeout: ${timeout}s)"
|
||||
echo "=== EDGE OUTPUT ==="
|
||||
cat "$EDGE_OUT"
|
||||
echo "=== NODE OUTPUT ==="
|
||||
cat "$NODE_OUT"
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
}
|
||||
|
||||
send_cmd() {
|
||||
LAST_CMD_START_LINE=$(($(wc -l < "$EDGE_OUT" | tr -d ' ') + 1))
|
||||
echo "[e2e] > $1"
|
||||
echo "$1" >&3
|
||||
sleep "$COMMAND_SETTLE_SECONDS"
|
||||
}
|
||||
|
||||
wait_for_node_registration
|
||||
send_cmd "/nodes"
|
||||
wait_for_edge_text_since "test-node (test-node)" "$LAST_CMD_START_LINE" "/nodes output"
|
||||
send_cmd "/capabilities"
|
||||
wait_for_edge_text_since "[node-test-node-capabilities]" "$LAST_CMD_START_LINE" "/capabilities output"
|
||||
wait_for_edge_text_since "targets = ${TARGET}" "$LAST_CMD_START_LINE" "/capabilities targets"
|
||||
send_cmd "/transport"
|
||||
send_cmd "$STRICT_PROMPT"
|
||||
wait_for_edge_text_since "[node-test-node-transport]" "$LAST_CMD_START_LINE" "/transport output"
|
||||
wait_for_edge_text_since "connected = true" "$LAST_CMD_START_LINE" "/transport connected status"
|
||||
send_cmd "$FIRST_PROMPT"
|
||||
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 1 start" "$RUN_TIMEOUT"
|
||||
wait_for_edge_text_since "$FIRST_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 1 response" "$RUN_TIMEOUT"
|
||||
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 1 completion" "$RUN_TIMEOUT"
|
||||
send_cmd "$SECOND_PROMPT"
|
||||
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=" "$LAST_CMD_START_LINE" "foreground run 2 start" "$RUN_TIMEOUT"
|
||||
wait_for_edge_text_since "$SECOND_EXPECTED" "$LAST_CMD_START_LINE" "foreground run 2 response" "$RUN_TIMEOUT"
|
||||
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "foreground run 2 completion" "$RUN_TIMEOUT"
|
||||
send_cmd "/session session2"
|
||||
wait_for_edge_pattern_since "session .*session2" "$LAST_CMD_START_LINE" "/session acknowledgement"
|
||||
send_cmd "/background on"
|
||||
send_cmd "Reply with exactly 'OK' again."
|
||||
wait_for_edge_pattern_since "background .*on" "$LAST_CMD_START_LINE" "/background on acknowledgement"
|
||||
send_cmd "$BACKGROUND_PROMPT"
|
||||
wait_for_edge_pattern_since "\\[node-test-node-event\\] start run_id=.*session=session2.*background=true" "$LAST_CMD_START_LINE" "background run start" "$RUN_TIMEOUT"
|
||||
wait_for_edge_text_since "$BACKGROUND_EXPECTED" "$LAST_CMD_START_LINE" "background run response" "$RUN_TIMEOUT"
|
||||
wait_for_edge_pattern_since "\\[node-test-node-event\\] complete run_id=" "$LAST_CMD_START_LINE" "background run completion" "$RUN_TIMEOUT"
|
||||
send_cmd "/background off"
|
||||
wait_for_edge_pattern_since "background .*off" "$LAST_CMD_START_LINE" "/background off acknowledgement"
|
||||
send_cmd "/sessions"
|
||||
wait_for_edge_text_since "[node-test-node-sessions]" "$LAST_CMD_START_LINE" "/sessions output"
|
||||
|
||||
if [ "$IS_PERSISTENT" -eq 1 ]; then
|
||||
send_cmd "/terminate-session"
|
||||
wait_for_edge_text_since "terminated session" "$LAST_CMD_START_LINE" "/terminate-session acknowledgement"
|
||||
fi
|
||||
|
||||
if [ "$HAS_STATUS" -eq 1 ]; then
|
||||
send_cmd "/status"
|
||||
wait_for_edge_text_since "[node-test-node-status]" "$LAST_CMD_START_LINE" "/status output" "$STATUS_TIMEOUT"
|
||||
fi
|
||||
|
||||
IDLE_FAIL=0
|
||||
|
|
@ -249,6 +388,14 @@ check_fail_markers() {
|
|||
check_grep "test-node" "$EDGE_OUT" "node registration not found"
|
||||
check_grep "start run_id=" "$EDGE_OUT" "run start not found"
|
||||
check_grep "complete run_id=" "$EDGE_OUT" "run completion not found"
|
||||
if [ "$(grep -c "\\[node-test-node-event\\] complete run_id=" "$EDGE_OUT" || true)" -lt 3 ]; then
|
||||
echo "[e2e] FAIL: expected at least 3 completed runs (foreground x2 + background x1)"
|
||||
FAIL=1
|
||||
fi
|
||||
check_grep "$FIRST_EXPECTED" "$EDGE_OUT" "foreground run 1 response not found"
|
||||
check_grep "$SECOND_EXPECTED" "$EDGE_OUT" "foreground run 2 response not found"
|
||||
check_grep "$BACKGROUND_EXPECTED" "$EDGE_OUT" "background run response not found"
|
||||
check_no_grep "\\[node-test-node-message\\] <empty>" "$EDGE_OUT" "empty node message found"
|
||||
|
||||
check_grep "\\[node-test-node-capabilities\\]" "$EDGE_OUT" "/capabilities output not found"
|
||||
check_grep "targets = ${TARGET}" "$EDGE_OUT" "/capabilities targets not found"
|
||||
|
|
|
|||
Loading…
Reference in a new issue