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 @@
///
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 <