diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go index e5cf25c..bb6fec0 100644 --- a/apps/node/internal/adapters/cli/cli.go +++ b/apps/node/internal/adapters/cli/cli.go @@ -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. diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index 311af2f..b034da5 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -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() diff --git a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go index 5a697a5..7565ca9 100644 --- a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go +++ b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go @@ -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) { diff --git a/apps/node/internal/adapters/cli/status/claude.go b/apps/node/internal/adapters/cli/status/claude.go index 626c877..dbf2760 100644 --- a/apps/node/internal/adapters/cli/status/claude.go +++ b/apps/node/internal/adapters/cli/status/claude.go @@ -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) } diff --git a/apps/node/internal/adapters/cli/status/claude_test.go b/apps/node/internal/adapters/cli/status/claude_test.go index 6b74ab5..026e9c9 100644 --- a/apps/node/internal/adapters/cli/status/claude_test.go +++ b/apps/node/internal/adapters/cli/status/claude_test.go @@ -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") diff --git a/apps/web/README.md b/apps/web/README.md index cab113a..14120b0 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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 diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 10fa9b3..b599605 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 321b3b0..e0f624b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index fd35f1e..485fa71 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -43,6 +43,7 @@ export default async function Home() {

IOP 전체 Web Portal을 위한 최소 Next.js 스캐폴드입니다. 세부 도메인 화면은 아직 고정하지 않습니다.

+

웹 테스트 화면이 정상 렌더링되었습니다.

diff --git a/bin/control-plane b/bin/control-plane new file mode 100755 index 0000000..f456d4b Binary files /dev/null and b/bin/control-plane differ diff --git a/bin/edge b/bin/edge new file mode 100755 index 0000000..b733bf3 Binary files /dev/null and b/bin/edge differ diff --git a/bin/node b/bin/node new file mode 100755 index 0000000..69b0439 Binary files /dev/null and b/bin/node differ diff --git a/bin/web.sh b/bin/web.sh new file mode 100755 index 0000000..d12f508 --- /dev/null +++ b/bin/web.sh @@ -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" diff --git a/bin/worker b/bin/worker new file mode 100755 index 0000000..bf403b9 Binary files /dev/null and b/bin/worker differ diff --git a/configs/edge.yaml b/configs/edge.yaml index da543bd..86ba135 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -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" diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh index 50f2383..41ba19d 100755 --- a/scripts/e2e-smoke.sh +++ b/scripts/e2e-smoke.sh @@ -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 < "$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\\] " "$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"