From cd9d5830c795aa6fe435a93567d889334c5e1fa2 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 10:16:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20edge=20node=20registry,=20opsconsole,?= =?UTF-8?q?=20service=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20agent-ops=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edge node registry: 노드 레지스트리 기능 개선 - edge opsconsole: 콘솔 및 이벤트 처리 로직 수정 - edge service: 서비스 레이어 변경 - agent-ops: init-agent-ops, sync-pull, sync-push 스킬 및 스크립트 업데이트 - 새 스크립트 추가: entry-files.sh --- agent-ops/bin/entry-files.sh | 24 +++++ agent-ops/bin/init-agent-ops.sh | 13 +-- agent-ops/bin/sync.sh | 38 ++------ .../skills/common/init-agent-ops/SKILL.md | 14 +-- agent-ops/skills/common/sync-pull/SKILL.md | 2 +- agent-ops/skills/common/sync-push/SKILL.md | 21 ++++- apps/edge/internal/node/registry.go | 43 +++++++++ apps/edge/internal/node/registry_test.go | 13 +++ apps/edge/internal/opsconsole/console.go | 4 +- apps/edge/internal/opsconsole/console_test.go | 8 +- apps/edge/internal/opsconsole/events.go | 93 ++++++++++++------- apps/edge/internal/opsconsole/events_test.go | 53 +++++++++++ apps/edge/internal/service/service.go | 2 +- 13 files changed, 241 insertions(+), 87 deletions(-) create mode 100644 agent-ops/bin/entry-files.sh diff --git a/agent-ops/bin/entry-files.sh b/agent-ops/bin/entry-files.sh new file mode 100644 index 0000000..dfb6f90 --- /dev/null +++ b/agent-ops/bin/entry-files.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# Shared entry-point file list for init/sync scripts. +AGENT_OPS_ENTRY_FILES=("GEMINI.md" "CLAUDE.md" "AGENTS.md" ".cursorrules" ".clinerules") + +apply_agent_ops_entry_files() { + local rules_md="$1" + local target_root="$2" + + if [[ ! -f "$rules_md" ]]; then + if [[ -n "${YELLOW:-}" && -n "${RESET:-}" ]]; then + echo -e "${YELLOW} rules.md 없음, 진입 파일 재적용 건너뜀${RESET}" + else + echo " rules.md 없음, 진입 파일 재적용 건너뜀" + fi + return + fi + + local f + for f in "${AGENT_OPS_ENTRY_FILES[@]}"; do + cp "$rules_md" "$target_root/$f" + echo " 진입 파일 적용: $f" + done +} diff --git a/agent-ops/bin/init-agent-ops.sh b/agent-ops/bin/init-agent-ops.sh index d66e2ab..449c410 100755 --- a/agent-ops/bin/init-agent-ops.sh +++ b/agent-ops/bin/init-agent-ops.sh @@ -5,13 +5,16 @@ set -e +SCRIPT_DIR=$(realpath "$(dirname "$0")") +SOURCE_DIR=$(realpath "$SCRIPT_DIR/..") +source "$SCRIPT_DIR/entry-files.sh" + if [ -z "$1" ]; then echo "Usage: $0 " exit 1 fi TARGET_DIR=$(realpath "$1") -SOURCE_DIR=$(realpath "$(dirname "$0")/..") ARCHIVE_IGNORE_PATTERN="agent-task/archive/**" append_unique_line() { @@ -34,16 +37,14 @@ mkdir -p "$TARGET_DIR/agent-ops/skills/project" # 2. 공통 요소 복사 (프로젝트 전용 설정은 제외) cp "$SOURCE_DIR/.version" "$TARGET_DIR/agent-ops/" +rm -rf "$TARGET_DIR/agent-ops/bin" +cp -r "$SOURCE_DIR/bin" "$TARGET_DIR/agent-ops/" cp -r "$SOURCE_DIR/rules/common" "$TARGET_DIR/agent-ops/rules/" cp -r "$SOURCE_DIR/skills/common" "$TARGET_DIR/agent-ops/skills/" # 3. 에이전트 진입 파일 생성 (common/rules.md 복사) COMMON_RULES="$SOURCE_DIR/rules/common/rules.md" -cp "$COMMON_RULES" "$TARGET_DIR/GEMINI.md" -cp "$COMMON_RULES" "$TARGET_DIR/CLAUDE.md" -cp "$COMMON_RULES" "$TARGET_DIR/AGENTS.md" -cp "$COMMON_RULES" "$TARGET_DIR/.cursorrules" -cp "$COMMON_RULES" "$TARGET_DIR/.clinerules" +apply_agent_ops_entry_files "$COMMON_RULES" "$TARGET_DIR" # 4. AI ignore / permission 설정 append_unique_line "$TARGET_DIR/.geminiignore" "$ARCHIVE_IGNORE_PATTERN" diff --git a/agent-ops/bin/sync.sh b/agent-ops/bin/sync.sh index d65e10e..632853d 100755 --- a/agent-ops/bin/sync.sh +++ b/agent-ops/bin/sync.sh @@ -9,6 +9,9 @@ PROJECT_ROOT="$(cd "$AGENT_OPS_DIR/.." && pwd)" # ── 색상 ───────────────────────────────────────────────────────────────────── RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; RESET='\033[0m' +# ── 진입 파일 공통 관리 ────────────────────────────────────────────────────── +source "$SCRIPT_DIR/entry-files.sh" + # ── 대상 경로 해석 (폴더명 / 상대경로 / 절대경로) ──────────────────────────── resolve_target() { local input="$1" @@ -68,34 +71,6 @@ sync_common() { sync_folder "$src/bin" "$dst/bin" } -# ── rules.md 내용을 진입 파일들에 반영 ─────────────────────────────────────── -apply_entry_files() { - local rules_md="$1" - local target_root="$2" - - if [[ ! -f "$rules_md" ]]; then - echo -e "${YELLOW} rules.md 없음, 진입 파일 재적용 건너뜀${RESET}" - return - fi - - # 기본 자동 생성/갱신 대상 - local default_files=("CLAUDE.md" "GEMINI.md" ".cursorrules" "AGENTS.md") - # 존재할 때만 갱신 - local optional_files=(".clinerules") - - for f in "${default_files[@]}"; do - cp "$rules_md" "$target_root/$f" - echo " 진입 파일 적용: $f" - done - - for f in "${optional_files[@]}"; do - if [[ -f "$target_root/$f" ]]; then - cp "$rules_md" "$target_root/$f" - echo " 진입 파일 적용: $f" - fi - done -} - # ── 사용법 ──────────────────────────────────────────────────────────────────── usage() { echo "사용법: $0 [--pull] " @@ -144,10 +119,10 @@ if [[ "$PULL_MODE" == "1" ]]; then echo " 현재 버전: $SRC_VER | framework 버전: $DST_VER" sync_common "$DST_AO" "$SRC_AO" cp "$DST_AO/.version" "$SRC_AO/.version" - apply_entry_files "$DST_AO/rules/common/rules.md" "$PROJECT_ROOT" + apply_agent_ops_entry_files "$DST_AO/rules/common/rules.md" "$PROJECT_ROOT" cd "$PROJECT_ROOT" git add agent-ops/rules/common agent-ops/skills/common agent-ops/bin agent-ops/.version \ - CLAUDE.md GEMINI.md .cursorrules AGENTS.md + "${AGENT_OPS_ENTRY_FILES[@]}" git commit -m "sync: pull from agentic-framework v$DST_VER" git push echo -e "${GREEN}✓ 완료 (pull v$DST_VER)${RESET}" @@ -161,12 +136,13 @@ if [[ "$IS_FRAMEWORK" == "1" ]]; then if [[ ! -d "$DST_AO" ]]; then echo " 최초 진행: agent-ops 전체 복사" cp -r "$SRC_AO" "$TARGET/" + apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$TARGET" echo -e "${YELLOW} init-agent-ops 스킬로 초기화를 진행하세요.${RESET}" else echo " 이후 진행: common/ 동기화" sync_common "$SRC_AO" "$DST_AO" cp "$SRC_AO/.version" "$DST_AO/.version" - apply_entry_files "$SRC_AO/rules/common/rules.md" "$TARGET" + apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$TARGET" fi echo -e "${GREEN}✓ 완료 → $(basename "$TARGET")${RESET}" exit 0 diff --git a/agent-ops/skills/common/init-agent-ops/SKILL.md b/agent-ops/skills/common/init-agent-ops/SKILL.md index 83866f1..af2b836 100644 --- a/agent-ops/skills/common/init-agent-ops/SKILL.md +++ b/agent-ops/skills/common/init-agent-ops/SKILL.md @@ -62,8 +62,9 @@ description: 프로젝트 상태를 판별하고 agent-ops 기본 스캐폴드 | 파일 | 방법 | |------|------| -| `CLAUDE.md`,`GEMINI.md`,`AGENTS.md`, `.cursorrules`, `.clinerules` 등 진입 파일 | `rules/common/rules.md`를 에이전트별 파일명으로 프로젝트 루트에 복사 후 `rules/common/rules.md` 삭제 | +| `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.clinerules` 등 진입 파일 | `rules/common/rules.md`를 `agent-ops/bin/entry-files.sh`의 파일 목록으로 프로젝트 루트에 복사 | | `agent-ops/.version` | 공통 관리 레포의 VERSION 파일을 그대로 복사 (프레임워크 버전 추적용) | +| `agent-ops/bin/` | 공통 스크립트 전체 복사 (진입 파일 목록의 단일 기준인 `entry-files.sh` 포함) | | `agent-ops/rules/common/` | 공통 폴더 전체 복사 (수정 금지) | | `agent-ops/skills/common/` | 공통 폴더 전체 복사 (수정 금지) | | `agent-ops/rules/project/rules.md` | 프로젝트 분석 후 생성 | @@ -75,10 +76,12 @@ description: 프로젝트 상태를 판별하고 agent-ops 기본 스캐폴드 에이전트별 파일명: +실제 생성/동기화 대상 목록은 `agent-ops/bin/entry-files.sh`의 `AGENT_OPS_ENTRY_FILES`를 단일 기준으로 사용한다. + | 에이전트 | 파일명 | |---------|--------| -| Claude | `CLAUDE.md` | | Gemini | `GEMINI.md` | +| Claude | `CLAUDE.md` | | Kilo Code / OpenCode | `AGENTS.md` | | Cursor | `.cursorrules` | | Cline | `.clinerules` | @@ -167,8 +170,7 @@ common/rules.md와 내용이 중복되지 않도록 한다. - 프로젝트 구조, 모듈 경계, agent-ops 파일 유무를 분석하여 신규/운영중을 판별한다 2. **에이전트 진입 파일 생성** - - `rules/common/rules.md`를 에이전트별 파일명으로 프로젝트 루트에 복사한다 - - 복사 후 `rules/common/rules.md`를 삭제한다 + - `rules/common/rules.md`를 `agent-ops/bin/entry-files.sh`의 파일 목록으로 프로젝트 루트에 복사한다 3. **AI ignore / permission 기본 설정** - `.geminiignore`, `.aiexclude`, `.cursorignore`, `.clineignore`에 `agent-task/archive/**`를 추가한다 @@ -176,7 +178,7 @@ common/rules.md와 내용이 중복되지 않도록 한다. - `agent-task/archive/**` 제외는 `.gitignore`에 추가하지 않는다 4. **agent-ops 폴더 구조 복사** - - 공통 관리 레포의 `agent-ops/` 공통 폴더(rules/common, skills/common)를 복사한다 + - 공통 관리 레포의 `agent-ops/` 공통 폴더(bin, rules/common, skills/common)를 복사한다 - `agent-ops/.version` 파일을 복사한다 5. **rules/project/rules.md 생성** @@ -221,7 +223,7 @@ common/rules.md와 내용이 중복되지 않도록 한다. ## 실행 결과 검증 -- [ ] 진입 파일이 프로젝트 루트에 존재하고, 내용이 `rules/common/rules.md`와 일치하는가 +- [ ] `agent-ops/bin/entry-files.sh`의 모든 진입 파일이 프로젝트 루트에 존재하고, 내용이 초기화에 사용한 `rules/common/rules.md`와 일치하는가 - [ ] `agent-ops/rules/project/rules.md`가 생성되었고, 필수 항목(응답 언어, 프로젝트 개요, 기술 스택)이 포함되어 있는가 - [ ] 생성된 domain rules.md가 `domain-rule-template.md` 형식을 따르는가 - [ ] `rules/project/rules.md`의 도메인 매핑 테이블에 생성된 도메인이 모두 등록되어 있는가 diff --git a/agent-ops/skills/common/sync-pull/SKILL.md b/agent-ops/skills/common/sync-pull/SKILL.md index 9afcdb1..adc2c42 100644 --- a/agent-ops/skills/common/sync-pull/SKILL.md +++ b/agent-ops/skills/common/sync-pull/SKILL.md @@ -29,7 +29,7 @@ agent-ops/bin/sync.sh --pull - [ ] sync.sh 가 오류 없이 완료됐는가 - [ ] 현재 프로젝트 버전이 framework 버전과 일치하는가 -- [ ] 진입 파일(CLAUDE.md 등)이 갱신됐는가 +- [ ] `agent-ops/bin/entry-files.sh`의 모든 진입 파일이 갱신됐고, 내용이 framework의 `agent-ops/rules/common/rules.md`와 일치하는가 ## 금지 사항 diff --git a/agent-ops/skills/common/sync-push/SKILL.md b/agent-ops/skills/common/sync-push/SKILL.md index 7f18074..04884a1 100644 --- a/agent-ops/skills/common/sync-push/SKILL.md +++ b/agent-ops/skills/common/sync-push/SKILL.md @@ -1,13 +1,16 @@ --- name: sync-push -description: 현재 프로젝트의 agent-ops를 agentic-framework로 올린다. "agent-ops 싱크해", "agent-ops 동기화해", "agentic-framework에 올려줘" 요청 시 사용한다. +description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거나, agentic-framework의 공통 agent-ops를 대상 프로젝트로 push한다. "agent-ops 싱크해", "agent-ops 동기화해", "agentic-framework에 올려줘" 요청 시 사용한다. --- # sync-push ## 목적 -`agent-ops/bin/sync.sh`를 호출해 현재 프로젝트 → agentic-framework 방향으로 agent-ops를 올린다. +`agent-ops/bin/sync.sh`를 호출해 agent-ops 공통 파일을 push 방향으로 동기화한다. + +- 일반 프로젝트에서는 현재 프로젝트 → agentic-framework 방향으로 올린다. +- agentic-framework 원본 프로젝트에서는 agentic-framework → 대상 프로젝트 방향으로 보낸다. ## 언제 호출할지 @@ -28,6 +31,19 @@ description: 현재 프로젝트의 agent-ops를 agentic-framework로 올린다. 1. 사용자 요청에서 target 프로젝트명 또는 경로를 추출한다 2. **명시된 경우**: `agent-ops/bin/sync.sh ` 실행 3. **명시되지 않은 경우**: 사용자에게 대상 프로젝트 입력을 요청한다 +4. `sync.sh`는 현재 agentic-framework의 `agent-ops/rules/common/rules.md` 내용을 대상 프로젝트 루트의 진입 파일에 덮어쓴다 + +덮어쓰기 대상은 `init-agent-ops` 초기 세팅과 동일하며, 실제 목록은 `agent-ops/bin/entry-files.sh`의 `AGENT_OPS_ENTRY_FILES`를 단일 기준으로 사용한다. + +| 에이전트 | 파일명 | +|---------|--------| +| Gemini | `GEMINI.md` | +| Claude | `CLAUDE.md` | +| Kilo Code / OpenCode | `AGENTS.md` | +| Cursor | `.cursorrules` | +| Cline | `.clinerules` | + +대상 프로젝트에 기존 진입 파일이 있어도 보존하거나 병합하지 않고 `agent-ops/rules/common/rules.md` 내용으로 교체한다. ```bash agent-ops/bin/sync.sh @@ -37,6 +53,7 @@ agent-ops/bin/sync.sh - [ ] sync.sh 가 오류 없이 완료됐는가 - [ ] 버전 충돌 경고가 없었는가 — 있었다면 사용자에게 수동 머지 필요함을 알린다 +- [ ] agentic-framework에서 대상 프로젝트로 push한 경우, `agent-ops/bin/entry-files.sh`의 모든 진입 파일 내용이 현재 agentic-framework의 `agent-ops/rules/common/rules.md`와 일치하는가 ## 금지 사항 diff --git a/apps/edge/internal/node/registry.go b/apps/edge/internal/node/registry.go index c6319dd..abe6be3 100644 --- a/apps/edge/internal/node/registry.go +++ b/apps/edge/internal/node/registry.go @@ -2,6 +2,9 @@ package node import ( "fmt" + "sort" + "strconv" + "strings" "sync" toki "git.toki-labs.com/toki/common-proto-socket/go" @@ -21,6 +24,7 @@ type Registry struct { mu sync.RWMutex byID map[string]*NodeEntry byAlias map[string]*NodeEntry + byIndex map[int]*NodeEntry indexByID map[string]int nextIndex int } @@ -29,6 +33,7 @@ func NewRegistry() *Registry { return &Registry{ byID: make(map[string]*NodeEntry), byAlias: make(map[string]*NodeEntry), + byIndex: make(map[int]*NodeEntry), indexByID: make(map[string]int), } } @@ -50,6 +55,7 @@ func (r *Registry) Register(entry *NodeEntry) { } } r.byID[entry.NodeID] = entry + r.byIndex[entry.Index] = entry if entry.Alias != "" { r.byAlias[entry.Alias] = entry } @@ -78,6 +84,7 @@ func (r *Registry) Unregister(nodeID string) { if entry.Alias != "" { delete(r.byAlias, entry.Alias) } + delete(r.byIndex, entry.Index) delete(r.byID, nodeID) } } @@ -100,6 +107,9 @@ func (r *Registry) Resolve(ref string) (*NodeEntry, error) { if entry, ok := r.byAlias[ref]; ok { return entry, nil } + if entry, ok := r.resolveDisplayLabelLocked(ref); ok { + return entry, nil + } return nil, fmt.Errorf("node %q not found", ref) } @@ -114,6 +124,30 @@ func (r *Registry) Resolve(ref string) (*NodeEntry, error) { return nil, fmt.Errorf("multiple nodes connected; select one with /node ") } +func (r *Registry) resolveDisplayLabelLocked(ref string) (*NodeEntry, bool) { + idx, ok := parseDisplayNodeIndex(ref) + if !ok { + return nil, false + } + entry, ok := r.byIndex[idx] + return entry, ok +} + +func parseDisplayNodeIndex(ref string) (int, bool) { + if !strings.HasPrefix(ref, "node") { + return 0, false + } + raw := strings.TrimPrefix(ref, "node") + if raw == "" { + return 0, false + } + idx, err := strconv.Atoi(raw) + if err != nil || idx < 0 { + return 0, false + } + return idx, true +} + func (r *Registry) All() []*NodeEntry { r.mu.RLock() defer r.mu.RUnlock() @@ -121,6 +155,15 @@ func (r *Registry) All() []*NodeEntry { for _, e := range r.byID { out = append(out, e) } + sort.Slice(out, func(i, j int) bool { + if out[i].HasIndex != out[j].HasIndex { + return out[i].HasIndex + } + if out[i].HasIndex && out[i].Index != out[j].Index { + return out[i].Index < out[j].Index + } + return out[i].NodeID < out[j].NodeID + }) return out } diff --git a/apps/edge/internal/node/registry_test.go b/apps/edge/internal/node/registry_test.go index 0783983..0b5ff2c 100644 --- a/apps/edge/internal/node/registry_test.go +++ b/apps/edge/internal/node/registry_test.go @@ -45,6 +45,19 @@ func TestRegistry_Resolve_ByAliasOrID(t *testing.T) { } } +func TestRegistry_Resolve_ByDisplayLabel(t *testing.T) { + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{NodeID: "node-001", Alias: "local-node"}) + reg.Register(&edgenode.NodeEntry{NodeID: "node-002", Alias: "remote-node"}) + + if e, err := reg.Resolve("node0"); err != nil || e.NodeID != "node-001" { + t.Fatalf("Resolve(node0) = %+v, %v; want node-001", e, err) + } + if e, err := reg.Resolve("node1"); err != nil || e.NodeID != "node-002" { + t.Fatalf("Resolve(node1) = %+v, %v; want node-002", e, err) + } +} + func TestRegistry_Resolve_ImplicitSingleNodeOnly(t *testing.T) { reg := edgenode.NewRegistry() diff --git a/apps/edge/internal/opsconsole/console.go b/apps/edge/internal/opsconsole/console.go index 762f998..ef9634b 100644 --- a/apps/edge/internal/opsconsole/console.go +++ b/apps/edge/internal/opsconsole/console.go @@ -212,10 +212,10 @@ func PrintNodes(out io.Writer, nodes []edgeservice.NodeSnapshot, selectedRef str } for _, node := range nodes { marker := " " - if selectedRef != "" && (node.NodeID == selectedRef || node.Alias == selectedRef) { + if selectedRef != "" && (node.NodeID == selectedRef || node.Alias == selectedRef || node.Label == selectedRef) { marker = "* " } - fmt.Fprintf(out, "%s%s (%s)\n", marker, node.NodeID, node.Alias) + fmt.Fprintf(out, "%s%s = %s (%s)\n", marker, node.Label, node.NodeID, node.Alias) } } diff --git a/apps/edge/internal/opsconsole/console_test.go b/apps/edge/internal/opsconsole/console_test.go index 07e21b9..8f6a465 100644 --- a/apps/edge/internal/opsconsole/console_test.go +++ b/apps/edge/internal/opsconsole/console_test.go @@ -121,10 +121,10 @@ func TestPrintNodes_ShowsSelectedNode(t *testing.T) { PrintNodes(&out, svc.ListNodeSnapshots(), "alias-1") got := out.String() - if !strings.Contains(got, "* node-1 (alias-1)") { + if !strings.Contains(got, "* node0 = node-1 (alias-1)") { t.Errorf("expected selected node-1 to have marker, got:\n%s", got) } - if !strings.Contains(got, " node-2 (alias-2)") { + if !strings.Contains(got, " node1 = node-2 (alias-2)") { t.Errorf("expected non-selected node-2 to have no marker, got:\n%s", got) } } @@ -132,11 +132,11 @@ func TestPrintNodes_ShowsSelectedNode(t *testing.T) { func TestPrintNodes_AcceptsSnapshotDTO(t *testing.T) { var out bytes.Buffer PrintNodes(&out, []edgeservice.NodeSnapshot{ - {NodeID: "node-a", Alias: "alpha", Label: "alpha"}, + {NodeID: "node-a", Alias: "alpha", Label: "node0"}, }, "alpha") got := out.String() - if !strings.Contains(got, "* node-a (alpha)") { + if !strings.Contains(got, "* node0 = node-a (alpha)") { t.Errorf("expected DTO render to mark selected, got:\n%s", got) } } diff --git a/apps/edge/internal/opsconsole/events.go b/apps/edge/internal/opsconsole/events.go index 8a5c19a..acf697b 100644 --- a/apps/edge/internal/opsconsole/events.go +++ b/apps/edge/internal/opsconsole/events.go @@ -18,21 +18,25 @@ import ( // Foreground runs tracked via TrackForeground are suppressed from the // asynchronous stream so the SendRun loop owns their output. type EventRouter struct { - mu sync.Mutex - out io.Writer - registry *edgenode.Registry - logger *zap.Logger - foreground map[string]struct{} - streams map[string]*ResponseStream + mu sync.Mutex + out io.Writer + registry *edgenode.Registry + logger *zap.Logger + foreground map[string]struct{} + streams map[string]*ResponseStream + nodeLabels map[string]string + aliasLabels map[string]string } func NewEventRouter(out io.Writer, registry *edgenode.Registry, logger *zap.Logger) *EventRouter { return &EventRouter{ - out: out, - registry: registry, - logger: logger, - foreground: make(map[string]struct{}), - streams: make(map[string]*ResponseStream), + out: out, + registry: registry, + logger: logger, + foreground: make(map[string]struct{}), + streams: make(map[string]*ResponseStream), + nodeLabels: make(map[string]string), + aliasLabels: make(map[string]string), } } @@ -95,18 +99,49 @@ func (r *EventRouter) Drain(ctx context.Context, runEvents <-chan *iop.RunEvent, } func (r *EventRouter) NodeLabel(event *iop.RunEvent) string { + r.mu.Lock() + defer r.mu.Unlock() + return r.nodeLabelLocked(event) +} + +func (r *EventRouter) nodeLabelLocked(event *iop.RunEvent) string { nodeID := event.GetNodeId() + alias := event.GetNodeAlias() + return r.resolveNodeLabelLocked(nodeID, alias) +} + +func (r *EventRouter) edgeNodeLabelLocked(event *iop.EdgeNodeEvent) string { + nodeID := event.GetNodeId() + alias := event.GetAlias() + return r.resolveNodeLabelLocked(nodeID, alias) +} + +func (r *EventRouter) resolveNodeLabelLocked(nodeID, alias string) string { if r.registry != nil { if entry, ok := r.registry.Get(nodeID); ok { - return entry.DisplayLabel() + return r.cacheEntryLabelLocked(entry) } - if alias := event.GetNodeAlias(); alias != "" { + if alias != "" { if entry, err := r.registry.Resolve(alias); err == nil { - return entry.DisplayLabel() + return r.cacheEntryLabelLocked(entry) } } } - if alias := event.GetNodeAlias(); alias != "" { + if nodeID != "" { + if label := r.nodeLabels[nodeID]; label != "" { + if alias != "" { + r.aliasLabels[alias] = label + } + return label + } + } + if alias != "" { + if label := r.aliasLabels[alias]; label != "" { + if nodeID != "" { + r.nodeLabels[nodeID] = label + } + return label + } return alias } if nodeID != "" { @@ -115,25 +150,15 @@ func (r *EventRouter) NodeLabel(event *iop.RunEvent) string { return "unknown" } -func (r *EventRouter) edgeNodeLabel(event *iop.EdgeNodeEvent) string { - nodeID := event.GetNodeId() - if r.registry != nil { - if entry, ok := r.registry.Get(nodeID); ok { - return entry.DisplayLabel() - } - if alias := event.GetAlias(); alias != "" { - if entry, err := r.registry.Resolve(alias); err == nil { - return entry.DisplayLabel() - } - } +func (r *EventRouter) cacheEntryLabelLocked(entry *edgenode.NodeEntry) string { + label := entry.DisplayLabel() + if entry.NodeID != "" { + r.nodeLabels[entry.NodeID] = label } - if alias := event.GetAlias(); alias != "" { - return alias + if entry.Alias != "" { + r.aliasLabels[entry.Alias] = label } - if nodeID != "" { - return nodeID - } - return "unknown" + return label } func (r *EventRouter) printNodeEventLocked(event *iop.EdgeNodeEvent) { @@ -141,7 +166,7 @@ func (r *EventRouter) printNodeEventLocked(event *iop.EdgeNodeEvent) { return } - label := r.edgeNodeLabel(event) + label := r.edgeNodeLabelLocked(event) detail := transportCloseDetail(event.GetMetadata()) switch event.GetType() { case eventpkg.TypeNodeConnected: @@ -173,7 +198,7 @@ func (r *EventRouter) printAsyncLocked(event *iop.RunEvent) { } runID := event.GetRunId() - label := r.NodeLabel(event) + label := r.nodeLabelLocked(event) switch event.GetType() { case "start": fmt.Fprintf(r.out, "%s start run_id=%s session=%s background=%v\n", nodeEventPrefix(label), runID, event.GetSessionId(), event.GetBackground()) diff --git a/apps/edge/internal/opsconsole/events_test.go b/apps/edge/internal/opsconsole/events_test.go index d163efa..b5962b2 100644 --- a/apps/edge/internal/opsconsole/events_test.go +++ b/apps/edge/internal/opsconsole/events_test.go @@ -177,6 +177,59 @@ func TestEventRouterPrintsNodeLifecycleEvents(t *testing.T) { } } +func TestEventRouterKeepsCachedDisplayLabelForLateEvents(t *testing.T) { + var out bytes.Buffer + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alias-1"}) + router := NewEventRouter(&out, reg, nil) + + router.HandleNodeEvent(&iop.EdgeNodeEvent{ + Type: eventpkg.TypeNodeConnected, + NodeId: "node-1", + Alias: "alias-1", + Reason: eventpkg.ReasonRegistered, + }) + reg.Unregister("node-1") + + router.Handle(&iop.RunEvent{ + RunId: "run-late", + Type: "start", + NodeId: "node-1", + NodeAlias: "alias-1", + Background: true, + }) + router.Handle(&iop.RunEvent{ + RunId: "run-late", + Type: "delta", + Delta: "late payload", + NodeId: "node-1", + NodeAlias: "alias-1", + Background: true, + }) + router.Handle(&iop.RunEvent{ + RunId: "run-late", + Type: "complete", + Message: "done", + NodeId: "node-1", + NodeAlias: "alias-1", + Background: true, + }) + + got := out.String() + for _, want := range []string{ + "[node0-evt] start run_id=run-late", + "[node0-msg] late payload", + "[node0-evt] complete run_id=run-late detail=\"done\"", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected cached label output %q, got:\n%s", want, got) + } + } + if strings.Contains(got, "[alias-1-") || strings.Contains(got, "[node-1-") { + t.Fatalf("late event leaked fallback label, got:\n%s", got) + } +} + func TestBuildNodeCommandRequest_StatusUsesCurrentTarget(t *testing.T) { req, reqID := BuildNodeCommandRequest("cli", "codex", "default", 30) if reqID == "" { diff --git a/apps/edge/internal/service/service.go b/apps/edge/internal/service/service.go index 6675994..48812ba 100644 --- a/apps/edge/internal/service/service.go +++ b/apps/edge/internal/service/service.go @@ -33,7 +33,7 @@ func (s *Service) ListNodes() []*edgenode.NodeEntry { } // NodeSnapshot is a surface-neutral view of a registered node, suitable for -// CLI, HTTP, or other transports. Label resolves to Alias when present. +// CLI, HTTP, or other transports. Label is the short display label (node0, node1). type NodeSnapshot struct { NodeID string Alias string