<!-- task=cli_command_responses/01_node_response_contract plan=0 tag=REFACTOR -->

# Node Command Response Contract Plan

## 이 파일을 읽는 구현 에이전트에게

구현 마지막 단계에서 반드시 `CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 검증 명령을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일은 그대로 두고 리뷰 준비 상태만 보고한다. 최종 판정, 로그 아카이브, `complete.log` 작성, task archive 이동은 code-review-skill 전용이다.

## 배경

`CLI Automation Runtime 안정화` 마일스톤의 다음 작업은 `SESSION_LIST`, `USAGE_STATUS`, `TRANSPORT_STATUS` 운영 명령 응답을 사람이 읽고 검증하기 쉬운 계약으로 고정하는 것이다. 현재 경로는 동작하지만 `SESSION_LIST`는 단일 문자열 중심이고, 오류/빈 usage 상태의 envelope 보존과 fallback 의미가 테스트로 충분히 잠겨 있지 않다. 이 계획은 proto 변경 없이 node와 cli adapter 내부 응답 payload를 보강한다.

## 분석 결과

### 읽은 파일

- `apps/node/internal/runtime/types.go`
- `apps/node/internal/node/node.go`
- `apps/node/internal/node/node_test.go`
- `apps/node/internal/adapters/cli/cli.go`
- `apps/node/internal/adapters/cli/status/status.go`
- `apps/node/internal/adapters/cli/cli_internal_test.go`
- `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/opsconsole/status.go`
- `apps/edge/internal/opsconsole/console_test.go`
- `apps/edge/internal/opsconsole/events_test.go`
- `apps/node/README.md`
- `apps/edge/README.md`

### 테스트 커버리지 공백

- `TRANSPORT_STATUS` 기본 필드는 `apps/node/internal/node/node_test.go`에 일부 있다. 빈 session_id의 default 보존과 response envelope 보존은 부족하다.
- error 응답은 문자열만 확인한다. request_id, type, adapter, target, session_id 보존은 부족하다.
- `SESSION_LIST`는 `count`와 `sessions` 문자열만 확인한다. mode, target, session_id를 별도 key로 노출하는 계약은 없다.
- `USAGE_STATUS`는 정상 raw output만 확인한다. checker가 nil status를 반환하거나 parsed field 없이 raw output만 반환하는 fallback 계약은 부족하다.

### 심볼 참조

- renamed/removed symbol: none.
- 새 dependency 추가 없음. `go.mod` 변경 불필요.

### 분할 판단

split decision policy를 먼저 평가했다. 공유 task group은 `cli_command_responses`다.

- `01_node_response_contract`: node와 cli adapter 응답 payload 및 node/cli package test를 소유한다. 선행 의존성 없음.
- `02+01_edge_console_smoke`: edge service/ops console 표시, README, field smoke 검증을 소유한다. `01_node_response_contract`가 `complete.log`를 만든 뒤 시작한다.

분할 근거: node/cli 응답 계약 변경과 edge console 표시/field smoke는 ownership, 검증 방식, 실패 원인이 다르다. node 응답 계약을 먼저 고정해야 edge 쪽 plan이 새 payload를 신뢰할 수 있다.

### 범위 결정 근거

- proto schema 변경은 제외한다. `NodeCommandResponse.result`와 `AgentUsageStatus` 안에서 표현 가능한 보강만 한다.
- edge service와 opsconsole 렌더링 변경은 `02+01_edge_console_smoke`로 넘긴다.
- 외부 CLI 실제 호출, bin shell field smoke는 `02+01_edge_console_smoke`에서 수행한다.
- Node local execution history와 Edge event aggregation 경계 확인은 별도 마일스톤 체크리스트 항목이므로 이 plan에 포함하지 않는다.

### 빌드 등급

build=`cloud-G06`, review=`cloud-G06`. proto는 바꾸지 않지만 node command 계약과 cli adapter 결과 payload를 만지는 protocol-adjacent hardening이라 cloud lane이 맞다.

## 구현 체크리스트

- [ ] [REFACTOR-1] Node command response envelope와 transport status 기본값을 고정한다.
- [ ] [REFACTOR-2] CLI `SESSION_LIST`와 `USAGE_STATUS` payload를 proto 변경 없이 보강한다.
- [ ] [REFACTOR-3] node/cli package tests로 응답 계약을 고정하고 중간/최종 검증을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## [REFACTOR-1] Node command response envelope와 transport status 기본값을 고정한다

### 문제

[node.go](/config/workspace/iop/apps/node/internal/node/node.go:246)의 `handleTransportStatusCommand`는 result의 `session_id`를 default로 정규화하지만 response `SessionId`는 원 request 값을 그대로 둔다. [node.go](/config/workspace/iop/apps/node/internal/node/node.go:285)의 error response도 envelope는 보존하지만 테스트가 request echo를 고정하지 않는다.

Before:

```go
// apps/node/internal/node/node.go:258
return &iop.NodeCommandResponse{
    RequestId: req.GetRequestId(),
    Type:      req.GetType(),
    Adapter:   req.GetAdapter(),
    Target:    req.GetTarget(),
    SessionId: req.GetSessionId(),
    Result:    result,
}
```

### 해결 방법

`handleTransportStatusCommand` response `SessionId`도 `normalizeSessionID(req.GetSessionId())`로 맞춘다. `commandErrorResponse`는 기존 echo behavior를 유지하되 테스트로 request metadata 보존을 고정한다.

After:

```go
return &iop.NodeCommandResponse{
    RequestId: req.GetRequestId(),
    Type:      req.GetType(),
    Adapter:   req.GetAdapter(),
    Target:    req.GetTarget(),
    SessionId: normalizeSessionID(req.GetSessionId()),
    Result:    result,
}
```

### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/node/node.go`: `TRANSPORT_STATUS` response `SessionId` 정규화.
- [ ] `apps/node/internal/node/node_test.go`: empty session transport status가 `default`를 반환하는 테스트 추가.
- [ ] `apps/node/internal/node/node_test.go`: missing adapter, unsupported command, adapter error 응답이 request_id/type/adapter/target/session_id를 보존하는 테스트 추가.

### 테스트 작성

작성한다.

- `TestOnCommandRequest_TransportStatusDefaultsSessionID`: empty session input에서 response/result session_id 모두 `default`인지 검증.
- `TestOnCommandRequest_ErrorResponsesPreserveEnvelope`: error 계열 응답이 request envelope를 유지하는지 검증.

### 중간 검증

```bash
go test -count=1 ./apps/node/internal/node
```

기대 결과: package PASS.

## [REFACTOR-2] CLI `SESSION_LIST`와 `USAGE_STATUS` payload를 proto 변경 없이 보강한다

### 문제

[cli.go](/config/workspace/iop/apps/node/internal/adapters/cli/cli.go:262)의 session list는 `sessions = mode:target/session_id` 문자열만 제공한다. 사람이 읽기에는 괜찮지만 machine-readable key가 없어 edge console이나 향후 운영 표면이 다시 파싱해야 한다. [cli.go](/config/workspace/iop/apps/node/internal/adapters/cli/cli.go:238)의 usage status는 checker가 nil status를 반환할 경우 panic 위험이 있고, parsed field가 비어 있는 상태의 의미를 metadata로 남기지 않는다.

Before:

```go
// apps/node/internal/adapters/cli/cli.go:279
result := map[string]string{
    "count":    strconv.Itoa(len(entries)),
    "sessions": strings.Join(entries, ","),
}
```

### 해결 방법

기존 `count`와 `sessions`는 호환성을 위해 유지한다. 추가로 정렬된 session별 key를 넣는다: `session.0.mode`, `session.0.target`, `session.0.session_id`. usage status는 nil checker result를 명시적 error로 반환하고, parsed data가 없는 raw-only 상태에는 metadata `parse_status=raw_only`를 넣는다. raw output도 비어 있으면 metadata `parse_status=empty`를 넣는다.

After:

```go
result := map[string]string{
    "count":    strconv.Itoa(len(entries)),
    "sessions": strings.Join(entries, ","),
}
for i, entry := range parsedEntries {
    prefix := fmt.Sprintf("session.%d.", i)
    result[prefix+"mode"] = entry.mode
    result[prefix+"target"] = entry.target
    result[prefix+"session_id"] = entry.sessionID
}
```

### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/adapters/cli/cli.go`: small internal struct/helper로 session entries를 정렬 가능한 typed snapshot으로 만든다.
- [ ] `apps/node/internal/adapters/cli/cli.go`: `count`, `sessions` 기존 key를 유지하고 `session.N.*` key를 추가한다.
- [ ] `apps/node/internal/adapters/cli/cli.go`: `handleUsageStatus`에서 nil status를 error로 처리한다.
- [ ] `apps/node/internal/adapters/cli/cli.go`: raw-only/empty usage status metadata를 보강한다.
- [ ] `apps/node/internal/adapters/cli/status/status.go`: `ToRuntime`이 metadata map aliasing 문제를 일으키지 않도록 필요하면 copy를 추가한다.

### 테스트 작성

작성한다.

- `TestHandleSessionList_PopulatedSnapshot`: 기존 `sessions` 문자열 기대값은 유지하고 `session.0.mode`, `session.0.target`, `session.0.session_id` 등을 검증.
- `TestCLIHandleCommandUsageStatusUsesSelectedAgent`: metadata 보존 검증 추가.
- `TestCLIHandleCommandUsageStatusNilCheckerResult`: nil status가 명확한 error인지 검증.
- `TestCLIHandleCommandUsageStatusRawOnlyMetadata`: parsed fields 없이 raw output만 있을 때 metadata가 보강되는지 검증.

### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli
```

기대 결과: package PASS.

## [REFACTOR-3] node/cli package tests로 응답 계약을 고정하고 중간/최종 검증을 실행한다

### 문제

마일스톤 완료 기준은 CLI adapter 변경 시 대상 패키지 테스트와 field smoke 기준을 함께 요구한다. 이 subtask는 node/cli 계약만 소유하므로 field smoke는 다음 subtask로 넘기되, node와 cli package는 fresh test로 고정해야 한다.

### 해결 방법

REFACTOR-1, REFACTOR-2 이후 node/cli 대상 패키지를 `-count=1`로 실행한다. 이 subtask의 리뷰 stub에는 실제 출력 전체를 붙인다.

### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/node/node_test.go`: 새 테스트가 package 전체에서 통과한다.
- [ ] `apps/node/internal/adapters/cli/cli_internal_test.go`: session list 보강 테스트가 package 전체에서 통과한다.
- [ ] `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go`: usage status fallback 테스트가 package 전체에서 통과한다.

### 테스트 작성

REFACTOR-1과 REFACTOR-2에서 작성한다.

### 중간 검증

```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters/cli
```

기대 결과: 두 package PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/node/node.go` | REFACTOR-1 |
| `apps/node/internal/node/node_test.go` | REFACTOR-1, REFACTOR-3 |
| `apps/node/internal/adapters/cli/cli.go` | REFACTOR-2 |
| `apps/node/internal/adapters/cli/status/status.go` | REFACTOR-2 |
| `apps/node/internal/adapters/cli/cli_internal_test.go` | REFACTOR-2, REFACTOR-3 |
| `apps/node/internal/adapters/cli/lifecycle_blackbox_test.go` | REFACTOR-2, REFACTOR-3 |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/node ./apps/node/internal/adapters/cli
```

기대 결과: package PASS. Fresh execution required; cached output is not acceptable.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
