refactor(bridge): 실행 경계 선행 안정화를 반영한다

원격 터미널 브리지 POC 전에 Client HTTP lifecycle과 Edge run result surface 계약을 고정해야 하므로 관련 구현, 테스트, 로드맵 상태를 함께 정리한다.
This commit is contained in:
toki 2026-06-07 08:40:36 +09:00
parent b5c6ad8107
commit 672b4cdb5d
24 changed files with 1252 additions and 94 deletions

View file

@ -12,7 +12,7 @@ Edge, Node, Control Plane, Client, proto/config 경계에서 책임이 섞인
## 상태
[진행중]
[완료]
## 구현 잠금
@ -61,19 +61,19 @@ Control Plane과 Flutter Client가 multi-edge 운영면으로 커질 때 선형
공개 계약과 tracked 예시 파일이 실제 운영 원칙과 어긋나지 않게 정리한다.
- [x] [status-config-summary] Control Plane status contract에서 full `NodeConfigPayload` 노출 여지를 제거하거나 summary-only DTO로 고정한다. 검증: `go test -count=1 ./apps/control-plane/... ./packages/go/... ./proto/gen/...`
- [ ] [proto-legacy] legacy scheduling/node registration proto placeholder를 deprecated/reserved/remove 중 하나로 정리하고 생성 코드와 call-site를 맞춘다.
- [x] [proto-legacy] legacy scheduling/node registration proto placeholder를 deprecated/reserved/remove 중 하나로 정리하고 생성 코드와 call-site를 맞춘다. 검증: `agent-task/archive/2026/06/m-architecture-refactor-foundation/09_proto_legacy_contract/complete.log`
- [x] [sample-config] tracked `configs/*.yaml`에서 로컬 IP, host명, token, 위험한 bypass flag, 개인 경로를 제거하고 안전한 example/default 기준으로 재정리한다. 검증: `go test -count=1 ./apps/edge/cmd/edge ./apps/node/cmd/node`
- [x] [git-artifacts] tracked Flutter build artifact를 제거하고 package build 산출물이 다시 추적되지 않도록 ignore 규칙을 보강한다. 검증: `git ls-files | rg --sort path 'packages/flutter/iop_console/build|(^|/)build/.*(cache|unit_test_assets|native_assets|\.dill|\.otf|\.frag|NOTICES)'`
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: `cp-api-split`, `fleet-polling`, `client-state-split` 완료 로그와 `contract-hygiene``status-config-summary`, `sample-config`, `git-artifacts` 완료 근거를 반영했다. `proto-legacy` Task가 아직 남아 있어 Milestone 전체 완료 조건은 충족되지 않았다.
- 상태: 승인됨
- 요청일: 2026-06-07
- 완료 근거: `edge-boundary`, `node-runtime`, `cp-client`, `contract-hygiene`의 모든 기능 Task가 완료 근거와 함께 `[x]` 상태다. 마지막 남은 `proto-legacy``agent-task/archive/2026/06/m-architecture-refactor-foundation/09_proto_legacy_contract/complete.log``Roadmap Completion` 근거로 완료 처리했다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- [x] 사용자가 완료 결과를 확인했다
- [x] archive 이동을 승인했다
- 리뷰 코멘트: 사용자 요청으로 Milestone 종료를 승인하고 archive 이동을 반영했다.
## 범위 제외
@ -89,6 +89,7 @@ Control Plane과 Flutter Client가 multi-edge 운영면으로 커질 때 선형
- 표준선(선택): Control Plane은 Edge를 통해 관찰/제어하고, Edge는 Node 실행 그룹 상태를 소유하며, Node는 adapter execution과 terminal transport 실행자 역할을 유지한다. OpenAI-compatible/A2A 표면은 IOP native terminal 제어를 흡수하지 않는다.
- 선행 작업: 설계 부채 색인 리뷰
- 후속 작업: 원격 터미널 브리지 POC
- 최근 완료 작업: `agent-task/archive/2026/06/m-architecture-refactor-foundation/06_cp_api_split/complete.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/complete.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/complete.log`
- active plan: `agent-task/m-architecture-refactor-foundation/09_proto_legacy_contract/PLAN-cloud-G06.md`
- 최근 완료 작업: `agent-task/archive/2026/06/m-architecture-refactor-foundation/06_cp_api_split/complete.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/complete.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/complete.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/09_proto_legacy_contract/complete.log`
- active plan: 없음
- 확인 필요: 없음
- 잔여 리스크 처리: 완료 후보를 되돌릴 수준의 결함은 아니지만 원격 터미널 브리지 POC 전에 고정해야 할 Client HTTP lifecycle, Edge run surface, Node terminal core, typed adapter config 리스크를 `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md` 후속 Milestone으로 승격했다.

View file

@ -34,6 +34,10 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/domain-agent-specific-iop-removal.md`
- 요약: 독립 Control Plane 전환에 맞춰 IOP 활성 코드와 문서에서 전용 agent kind, bootstrap, smoke, 운영 문구를 제거했다.
- [완료] 전역 설계 부채 리팩토링 기반
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- 요약: 원격 터미널 브리지와 이후 운영 확장 전에 Edge, Node, Control Plane, Client, proto/config 경계의 설계 부채를 정리했고, 잔여 리스크는 후속 선행 안정화 마일스톤으로 넘겼다.
- [폐기] Specialized Agent proto-socket 연결 기반
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/specialized-agent-proto-socket-foundation.md`
- 요약: domain agent가 독립형 실행 방식으로 전환될 예정이므로 Edge 직접 proto-socket specialized peer 기준의 연결 기반 정리는 폐기한다.
@ -50,9 +54,9 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/domain-agent-message-boundary.md`
- 요약: 독립형 실행 전환으로 Edge 직접 domain payload boundary 정리가 현재 범위에서 필요 없어져 폐기한다.
- [진행중] 전역 설계 부채 리팩토링 기반
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- 요약: 원격 터미널 브리지와 이후 운영 확장 전에 Edge, Node, Control Plane, Client, proto/config 경계의 설계 부채를 정리한다.
- [계획] 브리지 선행 경계 안정화
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- 요약: 원격 터미널 브리지 POC 전에 남은 호환성/소유권 리스크를 Client HTTP lifecycle, Edge run surface, Node terminal core, typed adapter config 계약으로 고정한다.
- [계획] 원격 터미널 브리지 POC
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md`

View file

@ -0,0 +1,65 @@
# Milestone: 브리지 선행 경계 안정화
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
## 목표
원격 터미널 브리지 POC를 시작하기 전에 전역 설계 부채 리팩토링에서 남은 호환성/소유권 리스크를 실행 가능한 계약으로 고정한다.
새 terminal/native API가 legacy 실행 handle, provider-specific terminal core, 임시 설정 경로, 느슨한 client lifecycle에 기대지 않도록 선행 경계를 정리한다.
## 상태
[진행중]
## 구현 잠금
- 상태: 해제
- 결정 필요: 없음
## 범위
- Client HTTP repository의 기본 client 소유권과 dispose 경계 정리
- Edge service run 제출 결과에서 legacy `RunHandle` 의존을 줄이고 신규 표면의 `RunDispatch`/`RunStream` 소비 기준 고정
- Node terminal session core를 provider-specific CLI 구현에서 재사용 가능한 terminal transport 경계로 분리할 수 있는 최소 구조 확정
- `AdapterConfig.settings`를 legacy fallback으로만 유지하고 신규 adapter/terminal config는 typed proto 계약으로 표현한다는 guard 보강
## 기능
### Epic: [preflight-boundary] Bridge Preflight Boundary
원격 터미널 브리지 구현이 기존 호환 표면을 확장 경로로 착각하지 않도록 선행 계약을 고정한다.
- [x] [client-http-lifecycle] `HttpControlPlaneStatusRepository`가 기본 `http.Client`를 요청마다 새로 만들지 않고 소유/해제 경계를 명확히 하며, 주입된 client는 caller-owned로 유지한다. 검증: `cd apps/client && flutter test test/control_plane_status_controller_test.dart test/widget_test.dart`
- [ ] [edge-run-surface] Edge 신규 native/terminal 표면은 legacy combined `RunHandle` 대신 `RunDispatch``RunStream` 또는 동등한 surface-neutral 계약을 소비하도록 service interface와 테스트를 정리한다. 검증: `go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a`
- [ ] [terminal-core-boundary] persistent PTY 기반 `terminalSessionCore`를 CLI provider 내부 세부가 아니라 remote terminal transport가 재사용할 수 있는 Node terminal boundary로 분리하거나 명시 interface로 고정한다. 검증: `go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/...`
- [ ] [adapter-config-typed] `AdapterConfig.settings`는 구버전 Edge payload fallback으로만 허용하고, Edge mapper와 신규 config는 typed oneof/proto 메시지를 사용한다는 guard를 테스트로 고정한다. 검증: `make proto && go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/node/internal/adapters ./proto/gen/...`
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- 원격 터미널 브리지 open/input/output/resize/signal/close 기능 구현
- SSH/WinRM/serial 같은 신규 terminal transport 구현
- OpenAI-compatible/A2A 표면에 terminal 제어 기능 추가
- legacy `AdapterConfig.settings` fallback 즉시 제거
- Control Plane/Client terminal 운영 UI 구현
## 작업 컨텍스트
- 관련 경로: `apps/client/lib/control_plane_status_repository.dart`, `apps/edge/internal/service`, `apps/edge/internal/openai`, `apps/edge/internal/input/a2a`, `apps/node/internal/adapters/cli`, `proto/iop/runtime.proto`, `apps/edge/internal/node`, `apps/node/internal/adapters`
- 표준선(선택): 호환성 경로는 유지하되 신규 bridge/native 구현은 surface-neutral DTO, reusable terminal boundary, typed proto config를 기준으로 삼는다.
- 선행 작업: 전역 설계 부채 리팩토링 기반
- 후속 작업: 원격 터미널 브리지 POC
- 확인 필요: 없음
- 완료 근거: `[client-http-lifecycle]``cd apps/client && flutter test test/control_plane_status_controller_test.dart test/widget_test.dart` 통과.

View file

@ -0,0 +1,175 @@
<!-- task=m-bridge-boundary-hardening/01_edge_run_surface plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-07
task=m-bridge-boundary-hardening/01_edge_run_surface, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- Task ids:
- `edge-run-surface`: Edge 신규 native/terminal 표면은 legacy combined `RunHandle` 대신 `RunDispatch`와 `RunStream` 또는 동등한 surface-neutral 계약을 소비하도록 service interface와 테스트를 정리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-bridge-boundary-hardening/01_edge_run_surface/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-bridge-boundary-hardening`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Run Surface-Neutral DTO Consumption | [x] |
## 구현 체크리스트
- [x] OpenAI/A2A용 run service interface와 collector가 `RunDispatch`/`RunStream`을 직접 소비하도록 바꾸고 `edge-run-surface` 검증 명령을 통과시킨다.
- [x] 기존 `Service.SubmitRun` 호환성은 유지하되 신규 DTO 소비를 보장하는 service/openai/a2a 테스트를 갱신한다.
- [x] 사용자 실행 파이프라인 영향에 대해 보조 smoke/full-cycle 수행 여부 또는 생략 사유를 검증 결과에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-bridge-boundary-hardening/01_edge_run_surface/`를 `agent-task/archive/YYYY/MM/m-bridge-boundary-hardening/01_edge_run_surface/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-bridge-boundary-hardening`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-bridge-boundary-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획에서는 `Service.SubmitRun`이 기존 호환성을 위해 `*RunHandle`을 계속 반환하도록 하고 local interface 수준에서 DTO wrapper로 좁히려 했으나, Go의 인터페이스 시그니처 엄격성(공변 반환 타입 미지원)으로 인해 `Service.SubmitRun` 자체의 반환 타입을 `RunResult` 인터페이스로 변경해야 했습니다. 이에 따라 `opsconsole/console.go` 및 call site들의 DTO 소비 경로를 `handle.Dispatch()` 및 `handle.Stream()`를 사용하도록 함께 정비했습니다.
## 주요 설계 결정
`edgeservice.Service.SubmitRun`이 Go 인터페이스 시그니처 호환성을 만족하도록 반환 타입을 `RunResult`로 선언하여, 별도 래퍼 보일러플레이트 없이 `openai/runService` 및 `a2a/runService` 인터페이스에 `*Service`를 직접 주입해 사용할 수 있게 하였습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- OpenAI/A2A interfaces and collectors no longer require `*RunHandle` as their direct surface contract.
- `Service.SubmitRun` compatibility and close/unsubscribe behavior are preserved.
- Run timeout, node disconnect, error/cancelled handling remain equivalent.
- Verification output is pasted verbatim.
## 검증 결과
각 유닛 테스트 및 E2E 보조 smoke 테스트 명령을 실행하여 모든 검증을 성공적으로 마쳤습니다.
### API-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a
ok iop/apps/edge/internal/service 0.006s
ok iop/apps/edge/internal/openai 1.508s
ok iop/apps/edge/internal/input/a2a 0.006s
```
### 최종 검증
전체 레포지토리의 Go 패키지 및 3개 smoke 테스트 스크립트를 포함한 full-cycle 검증 결과는 아래와 같습니다.
1. 전체 Go 패키지 유닛 테스트 (`go test ./...`):
```text
ok iop/apps/control-plane/cmd/control-plane (cached)
ok iop/apps/control-plane/internal/wire (cached)
ok iop/apps/edge/cmd/edge 0.037s
ok iop/apps/edge/internal/bootstrap 0.019s
ok iop/apps/edge/internal/controlplane (cached)
ok iop/apps/edge/internal/edgecmd (cached)
ok iop/apps/edge/internal/events (cached)
ok iop/apps/edge/internal/input 0.006s
ok iop/apps/edge/internal/input/a2a 0.006s
ok iop/apps/edge/internal/node (cached)
ok iop/apps/edge/internal/openai 1.508s
ok iop/apps/edge/internal/opsconsole 0.007s
ok iop/apps/edge/internal/service 0.005s
ok iop/apps/edge/internal/transport (cached)
```
2. E2E mock 보조 smoke 테스트 (`./scripts/e2e-smoke.sh`):
```text
[e2e] Auxiliary smoke test PASSED.
```
3. OpenAI Ollama 입력 표면 보조 smoke 테스트 (`./scripts/e2e-openai-ollama.sh`):
```text
[openai-ollama] OpenAI-compatible Ollama serving test PASSED.
```
4. Control Plane-Edge wire 보조 smoke 테스트 (`./scripts/e2e-control-plane-edge-wire.sh`):
```text
[cp-edge-wire] Control Plane-Edge wire smoke PASSED.
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 리뷰어 검증:
- `go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a`: PASS
- `go test ./...`: PASS
- `go test -count=1 ./apps/edge/...`: PASS
- `./scripts/e2e-smoke.sh`: PASS
- `./scripts/e2e-openai-ollama.sh`: PASS
- `./scripts/e2e-control-plane-edge-wire.sh`: PASS
- `scripts/dev/edge.sh` + `scripts/dev/node.sh` reviewer diagnostic: PASS; `/nodes`, `/capabilities`, `/transport`, message x2, `/sessions`, `/terminate-session` confirmed with deterministic `fake-cli`.
- 다음 단계: PASS 종결 절차를 진행해 `complete.log`를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,46 @@
# Complete - m-bridge-boundary-hardening/01_edge_run_surface
## 완료 일시
2026-06-07T08:29:52+09:00
## 요약
Edge OpenAI/A2A run surface를 `RunResult`/`RunDispatch`/`RunStream` 소비 경계로 정리한 1회 루프가 PASS로 완료되었다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | OpenAI/A2A/ops console call sites가 legacy `RunHandle` 직접 surface 의존 없이 `RunResult` DTO 계약을 소비함을 확인했다. |
## 구현/정리 내용
- `edgeservice.RunResult` 인터페이스를 추가하고 `RunHandle`은 legacy concrete adapter로 유지했다.
- OpenAI/A2A service interfaces, collectors, stream handlers, task store, ops console call sites가 `Dispatch()`와 `Stream()`을 통해 surface-neutral DTO를 소비하도록 정리했다.
- 관련 OpenAI/A2A fake service signatures와 테스트를 `edgeservice.RunResult` 반환 계약으로 갱신했다.
## 최종 검증
- `go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a` - PASS; 대상 3개 패키지 모두 `ok`.
- `go test ./...` - PASS; 전체 Go package 회귀 통과.
- `go test -count=1 ./apps/edge/...` - PASS; Edge 전체 package 현재 내용 기준 통과.
- `./scripts/e2e-smoke.sh` - PASS; 보조 edge-node smoke 통과.
- `./scripts/e2e-openai-ollama.sh` - PASS; OpenAI-compatible Ollama serving smoke 통과.
- `./scripts/e2e-control-plane-edge-wire.sh` - PASS; Control Plane-Edge wire smoke 통과.
- `scripts/dev/edge.sh` + `scripts/dev/node.sh` reviewer diagnostic - PASS; 임시 config와 deterministic `fake-cli`로 `/nodes`, `/capabilities`, `/transport`, message x2, `/sessions`, `/terminate-session` 확인.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- Completed task ids:
- `edge-run-surface`: PASS; evidence=`plan_local_G06_0.log`, `code_review_local_G06_0.log`; verification=`go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a`, `go test -count=1 ./apps/edge/...`, `scripts/dev/edge.sh + scripts/dev/node.sh reviewer diagnostic`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,163 @@
<!-- task=m-bridge-boundary-hardening/01_edge_run_surface plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
이 작업은 구현이 끝나도 active 파일을 유지하고, 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운 뒤 리뷰 준비 상태로 멈춘다. 구현 중 사용자 결정, 사용자 소유 외부 환경, 또는 계획 범위 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 남기고 중단한다. 구현 중 사용자에게 직접 질문하거나 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다.
## 배경
Milestone의 `edge-run-surface` Task는 신규 native/terminal 표면이 legacy combined `RunHandle`에 기대지 않도록 Edge service 경계를 고정하는 작업이다. 현재 service에는 `RunDispatch`와 `RunStream`이 이미 있지만 OpenAI와 A2A interface, collector, tests는 여전히 `*RunHandle`을 직접 주고받는다. 이 plan은 서비스 반환 호환성은 유지하면서 신규 표면이 DTO를 직접 소비하는 경계를 만든다.
## 사용자 리뷰 요청 흐름
구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- Task ids:
- `edge-run-surface`: Edge 신규 native/terminal 표면은 legacy combined `RunHandle` 대신 `RunDispatch`와 `RunStream` 또는 동등한 surface-neutral 계약을 소비하도록 service interface와 테스트를 정리한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/input/a2a/server.go`
- `apps/edge/internal/input/a2a/task_store.go`
- `apps/edge/internal/input/a2a/server_test.go`
- `apps/edge/internal/input/a2a/task_store_test.go`
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md`를 읽었고, domain routing에 따라 `edge-smoke`, 사용자 실행 파이프라인 영향 기준으로 `testing-smoke`를 읽었다.
- 적용 명령: `go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a`.
- 사전 확인 결과: 위 명령은 현재 checkout에서 성공했다.
- 실행 파이프라인 영향: service/openai/a2a run stream 경로 변경이므로 구현 후 보조 smoke 또는 full-cycle 실제 구동 수행 여부와 생략 사유를 review stub에 기록한다.
### 테스트 커버리지 공백
- OpenAI non-stream/stream/strict stream은 `apps/edge/internal/openai/server_test.go`가 기존 회귀를 보유한다.
- A2A blocking/background/cancel/task mapping은 `server_test.go`와 `task_store_test.go`가 보유한다.
- 현재 공백: OpenAI/A2A fake service interface가 `RunHandle` 반환을 허용해 신규 표면이 DTO 기반인지 직접 보장하지 않는다. 구현 중 fake interface와 collector signature를 DTO 기반으로 바꿔 회귀를 추가한다.
### 심볼 참조
- 변경 예정: `runService.SubmitRun` 반환 타입 또는 wrapper interface, `collectRunResult`, `streamChatCompletion`, `TaskStore.collect*`.
- 현재 `RunHandle` 참조: `rg --sort path "RunHandle" apps/edge/internal/service apps/edge/internal/openai apps/edge/internal/input/a2a`로 확인한 OpenAI/A2A/service/test call sites.
### 분할 판단
- split decision policy를 적용했다.
- 공유 task group: `m-bridge-boundary-hardening`.
- sibling subtasks:
- `01_edge_run_surface`: Edge service/OpenAI/A2A surface-neutral run contract. 독립.
- `02_terminal_core_boundary`: Node PTY terminal core boundary. 독립.
- `03_adapter_config_typed`: proto/adapter config typed contract. 독립.
- 이 subtask는 다른 sibling의 `complete.log`를 필요로 하지 않는다.
### 범위 결정 근거
- 원격 터미널 bridge open/input/output/resize/signal/close 기능은 Milestone 범위 제외이므로 구현하지 않는다.
- ops console은 이미 `SubmitRun` 후 handle drain이 아니라 event bus router를 쓰는 경계라 이번 변경에서 제외한다.
- Control Plane/Client terminal UI는 제외한다.
### 빌드 등급
- `local-G06`: Edge 내부 API와 두 input surface를 함께 바꾸지만 범위가 세 패키지로 제한되고 테스트가 deterministic하다.
## 구현 체크리스트
- [ ] OpenAI/A2A용 run service interface와 collector가 `RunDispatch`/`RunStream`을 직접 소비하도록 바꾸고 `edge-run-surface` 검증 명령을 통과시킨다.
- [ ] 기존 `Service.SubmitRun` 호환성은 유지하되 신규 DTO 소비를 보장하는 service/openai/a2a 테스트를 갱신한다.
- [ ] 사용자 실행 파이프라인 영향에 대해 보조 smoke/full-cycle 수행 여부 또는 생략 사유를 검증 결과에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Run Surface-Neutral DTO Consumption
문제: `RunDispatch`와 `RunStream`은 이미 정의되어 있지만 `RunHandle`이 신규 surface의 interface까지 점유한다.
Before:
```go
// apps/edge/internal/openai/server.go:17
type runService interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
OllamaAPI(context.Context, edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error)
}
```
```go
// apps/edge/internal/input/a2a/server.go:19
type runService interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
CancelRun(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
}
```
해결 방법: service package에 `RunResult` 또는 동등 wrapper를 추가해 `RunDispatch`, `RunStream`, `Close`, `WaitTimeout`을 surface-neutral contract로 노출한다. `Service.SubmitRun`은 호환을 위해 `*RunHandle`을 반환해도 되지만, OpenAI/A2A local interface와 collector 함수는 `RunDispatch`/`RunStream` 중심 타입을 받게 한다. `RunHandle`은 legacy adapter로만 남긴다.
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/service/run_dispatch.go`: DTO 중심 helper/type 추가, `RunHandle` legacy 주석 갱신.
- [ ] `apps/edge/internal/openai/server.go`: `runService` interface 반환 타입을 DTO wrapper로 축소.
- [ ] `apps/edge/internal/openai/chat_handler.go`: dispatch metadata와 stream을 분리해 response model/run id 참조.
- [ ] `apps/edge/internal/openai/run_result.go`: `RunStream`과 timeout 값을 받도록 collector signature 변경.
- [ ] `apps/edge/internal/openai/stream.go`: stream 함수 signature에서 `RunHandle` 제거.
- [ ] `apps/edge/internal/input/a2a/server.go`: task id 생성과 drain 호출에서 dispatch/stream wrapper 사용.
- [ ] `apps/edge/internal/input/a2a/task_store.go`: `collectBlocking`, `collectBackground`, `drain`에서 `RunHandle` 직접 의존 제거.
- [ ] 관련 tests fake service 반환 타입 갱신.
테스트 작성: 기존 OpenAI/A2A tests를 갱신하고, fake service가 `RunHandle`이 아니라 DTO wrapper를 반환하도록 바꾼다. 별도 신규 test 파일은 만들지 않는다.
중간 검증:
```bash
go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/run_dispatch.go` | API-1 |
| `apps/edge/internal/openai/server.go` | API-1 |
| `apps/edge/internal/openai/chat_handler.go` | API-1 |
| `apps/edge/internal/openai/run_result.go` | API-1 |
| `apps/edge/internal/openai/stream.go` | API-1 |
| `apps/edge/internal/openai/server_test.go` | API-1 |
| `apps/edge/internal/input/a2a/server.go` | API-1 |
| `apps/edge/internal/input/a2a/task_store.go` | API-1 |
| `apps/edge/internal/input/a2a/server_test.go` | API-1 |
| `apps/edge/internal/input/a2a/task_store_test.go` | API-1 |
## 최종 검증
```bash
go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/openai ./apps/edge/internal/input/a2a
```
예상 결과: 세 패키지 모두 `ok`.
사용자 실행 파이프라인 영향 기록: 보조 smoke/full-cycle 실제 구동을 수행했으면 명령과 출력을 붙이고, 수행하지 못했으면 `testing-smoke` 기준으로 사유와 남은 위험을 `CODE_REVIEW`에 기록한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,121 @@
<!-- task=m-bridge-boundary-hardening/02_terminal_core_boundary plan=0 tag=REFACTOR -->
# Code Review Reference - REFACTOR
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-07
task=m-bridge-boundary-hardening/02_terminal_core_boundary, plan=0, tag=REFACTOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- Task ids:
- `terminal-core-boundary`: persistent PTY 기반 `terminalSessionCore`를 CLI provider 내부 세부가 아니라 remote terminal transport가 재사용할 수 있는 Node terminal boundary로 분리하거나 명시 interface로 고정한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md``code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md``plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-bridge-boundary-hardening/02_terminal_core_boundary/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-bridge-boundary-hardening`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REFACTOR-1] Extract Node-Owned Terminal Core Boundary | [ ] |
## 구현 체크리스트
- [ ] PTY terminal core를 `apps/node/internal/terminal` 또는 동등 Node-owned boundary로 분리하고 `terminal-core-boundary` 검증 명령을 통과시킨다.
- [ ] CLI persistent executor가 새 boundary를 소비하도록 바꾸되 provider별 filtering/session behavior를 변경하지 않는다.
- [ ] 사용자 실행 파이프라인 영향에 대해 보조 smoke/full-cycle 수행 여부 또는 생략 사유를 검증 결과에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-bridge-boundary-hardening/02_terminal_core_boundary/``agent-task/archive/YYYY/MM/m-bridge-boundary-hardening/02_terminal_core_boundary/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-bridge-boundary-hardening`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-bridge-boundary-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md``CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- New terminal boundary is not under `adapters/cli` and does not import CLI provider internals.
- CLI persistent behavior, prompt write delay, raw chunk output, resize/signal/close semantics remain covered.
- No remote terminal bridge protocol feature was added in this task.
- Verification output is pasted verbatim.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### REFACTOR-1 중간 검증
```bash
$ go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
(output)
```
### 최종 검증
```bash
$ go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/...
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,164 @@
<!-- task=m-bridge-boundary-hardening/02_terminal_core_boundary plan=0 tag=REFACTOR -->
# Plan - REFACTOR
## 이 파일을 읽는 구현 에이전트에게
이 작업은 구현 후 active 파일을 유지하고, 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운 뒤 리뷰 준비 상태로 멈춘다. 구현 중 사용자 결정, 사용자 소유 외부 환경, 또는 계획 범위 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 남기고 중단한다. 구현 중 사용자에게 직접 질문하거나 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다.
## 배경
Milestone의 `terminal-core-boundary` Task는 persistent PTY 기반 terminal core가 provider-specific CLI 구현 세부에 갇히지 않도록 Node terminal boundary를 고정하는 작업이다. 현재 `terminalSessionCore``apps/node/internal/adapters/cli` 내부 private 타입이고, 출력 타입도 `cliOutput`에 묶여 있다. remote terminal bridge POC 전에 이 core를 Node 내부 재사용 경계로 옮기거나 명시 interface로 고정해야 한다.
## 사용자 리뷰 요청 흐름
구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- Task ids:
- `terminal-core-boundary`: persistent PTY 기반 `terminalSessionCore`를 CLI provider 내부 세부가 아니라 remote terminal transport가 재사용할 수 있는 Node terminal boundary로 분리하거나 명시 interface로 고정한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `go.mod`
- `apps/node/internal/adapters/cli/cli.go`
- `apps/node/internal/adapters/cli/persistent.go`
- `apps/node/internal/adapters/cli/terminal_session.go`
- `apps/node/internal/adapters/cli/terminal_session_test.go`
- `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go`
- `apps/node/internal/adapters/cli/status/tail_buffer.go`
- `apps/node/internal/runtime/types.go`
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md`를 읽었고, domain routing에 따라 `node-smoke`, 사용자 실행 파이프라인 영향 기준으로 `testing-smoke`를 읽었다.
- 적용 명령: `go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/...`.
- 사전 확인 결과: 위 명령은 현재 checkout에서 성공했다.
- PTY/terminal 동작은 환경 민감도가 있으므로 실패 시 `command -v sh`, PTY 지원 skip 여부, 실제 stdout/stderr를 review stub에 기록한다.
### 테스트 커버리지 공백
- `terminal_session_test.go`가 prompt write, snapshot, resize, close/idempotency, write/signal after close를 검증한다.
- `persistent_execute_blackbox_test.go`가 terminal persistent execution, prompt replay, cancel, marker, raw chunk, wide PTY를 검증한다.
- 현재 공백: core가 private CLI 타입(`cliOutput`, `status.Snapshot`)에 묶여 있어 package boundary 자체를 테스트하지 못한다. 새 boundary package test를 추가해 CLI와 무관한 core contract를 검증한다.
### 심볼 참조
- 변경 예정: `terminalSessionCore`, `terminalSessionOptions`, `startTerminalSessionCore`, `cliOutput`, `status.Snapshot`.
- 현재 참조: `apps/node/internal/adapters/cli/terminal_session.go`, `apps/node/internal/adapters/cli/persistent.go`, `apps/node/internal/adapters/cli/cli.go`, `apps/node/internal/adapters/cli/terminal_session_test.go`.
### 분할 판단
- split decision policy를 적용했다.
- 공유 task group: `m-bridge-boundary-hardening`.
- sibling subtasks:
- `01_edge_run_surface`: Edge run surface. 독립.
- `02_terminal_core_boundary`: Node terminal package/interface boundary. 독립.
- `03_adapter_config_typed`: typed adapter config contract. 독립.
- 이 subtask는 다른 sibling의 `complete.log`를 필요로 하지 않는다.
### 범위 결정 근거
- remote terminal bridge open/input/output/resize/signal/close protocol은 구현하지 않는다.
- SSH/WinRM/serial transport는 범위 제외다.
- CLI provider별 filtering, Claude screen parsing, opencode/codex/antigravity session state는 이번 변경에서 동작 변경 없이 call site 조정만 한다.
### 빌드 등급
- `cloud-G07`: PTY/process control과 terminal-agent behavior가 중심이며, package boundary 이동 중 미묘한 session lifecycle 회귀가 hard-to-review일 수 있다.
## 구현 체크리스트
- [ ] PTY terminal core를 `apps/node/internal/terminal` 또는 동등 Node-owned boundary로 분리하고 `terminal-core-boundary` 검증 명령을 통과시킨다.
- [ ] CLI persistent executor가 새 boundary를 소비하도록 바꾸되 provider별 filtering/session behavior를 변경하지 않는다.
- [ ] 사용자 실행 파이프라인 영향에 대해 보조 smoke/full-cycle 수행 여부 또는 생략 사유를 검증 결과에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REFACTOR-1] Extract Node-Owned Terminal Core Boundary
문제: PTY core가 CLI adapter private 타입으로 갇혀 있어 remote terminal transport가 재사용할 수 없다.
Before:
```go
// apps/node/internal/adapters/cli/terminal_session.go:23
type terminalSessionCore struct {
cmd *exec.Cmd
input io.WriteCloser
output io.ReadCloser
tail *status.TailBuffer
outputCh chan cliOutput
doneCh chan error
closed bool
mu sync.Mutex
}
```
```go
// apps/node/internal/adapters/cli/persistent.go:345
if profile.Terminal {
opts := terminalSessionOptions{
Command: profile.Command,
Args: profile.Args,
Env: profile.Env,
}
core, err := startTerminalSessionCore(context.Background(), opts)
```
해결 방법: `apps/node/internal/terminal` package를 만들고 `Session`, `Options`, `Output`, `Snapshot` 타입과 `StartSession(ctx, opts)`를 정의한다. `terminal.Session``Output() <-chan terminal.Output`, `Done() <-chan error`, `WritePrompt`, `WriteInput`, `Resize`, `Signal`, `Snapshot`, `Close`를 제공한다. CLI package는 `terminal.Output.Text``cliOutput{text: ...}`로 어댑트하고, markerLine 없는 raw terminal semantics를 유지한다.
수정 파일 및 체크리스트:
- [ ] `apps/node/internal/terminal/session.go`: PTY core와 public Node-internal interface 구현.
- [ ] `apps/node/internal/terminal/session_test.go`: 기존 terminal core tests 이동/갱신.
- [ ] `apps/node/internal/adapters/cli/terminal_session.go`: 제거하거나 CLI adapter shim만 남긴다.
- [ ] `apps/node/internal/adapters/cli/cli.go`: `profileSession.core` 타입을 새 interface로 변경.
- [ ] `apps/node/internal/adapters/cli/persistent.go`: `StartSession` 호출과 output adapter 적용.
- [ ] `apps/node/internal/adapters/cli/terminal_session_test.go`: 새 package test로 이동하거나 CLI-specific integration만 남긴다.
- [ ] `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go`: 기존 behavior regression 유지.
테스트 작성: 새 `apps/node/internal/terminal/session_test.go`를 작성한다. 기존 CLI terminal core tests를 이동해 package boundary를 직접 검증하고, CLI persistent blackbox tests는 그대로 유지한다.
중간 검증:
```bash
go test -count=1 ./apps/node/internal/terminal ./apps/node/internal/adapters/cli
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/terminal/session.go` | REFACTOR-1 |
| `apps/node/internal/terminal/session_test.go` | REFACTOR-1 |
| `apps/node/internal/adapters/cli/terminal_session.go` | REFACTOR-1 |
| `apps/node/internal/adapters/cli/cli.go` | REFACTOR-1 |
| `apps/node/internal/adapters/cli/persistent.go` | REFACTOR-1 |
| `apps/node/internal/adapters/cli/terminal_session_test.go` | REFACTOR-1 |
| `apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go` | REFACTOR-1 |
## 최종 검증
```bash
go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/...
```
예상 결과: Node internal packages가 모두 `ok`, no-test packages는 `[no test files]`.
사용자 실행 파이프라인 영향 기록: 보조 smoke/full-cycle 실제 구동을 수행했으면 명령과 출력을 붙이고, 수행하지 못했으면 `testing-smoke` 기준으로 사유와 남은 위험을 `CODE_REVIEW`에 기록한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,143 @@
<!-- task=m-bridge-boundary-hardening/03_adapter_config_typed plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-07
task=m-bridge-boundary-hardening/03_adapter_config_typed, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- Task ids:
- `adapter-config-typed`: `AdapterConfig.settings`는 구버전 Edge payload fallback으로만 허용하고, Edge mapper와 신규 config는 typed oneof/proto 메시지를 사용한다는 guard를 테스트로 고정한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G06.md``code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md``plan_cloud_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-bridge-boundary-hardening/03_adapter_config_typed/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-bridge-boundary-hardening`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Typed Mock Adapter Config | [ ] |
## 구현 체크리스트
- [ ] Mock adapter config를 typed oneof/proto 메시지로 추가하고 generated Go/Dart 바인딩을 동기화한다.
- [ ] Edge mapper와 transport tests가 신규 Edge-generated payload에서 `settings`를 사용하지 않음을 검증하게 한다.
- [ ] Node legacy fallback tests는 유지하고, 필요한 경우 typed mock config가 기존 mock adapter와 호환됨을 검증한다.
- [ ] 사용자 실행 파이프라인 영향에 대해 보조 smoke/full-cycle 수행 여부 또는 생략 사유를 검증 결과에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G06_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G06_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-bridge-boundary-hardening/03_adapter_config_typed/``agent-task/archive/YYYY/MM/m-bridge-boundary-hardening/03_adapter_config_typed/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-bridge-boundary-hardening`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-bridge-boundary-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md``CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Edge-generated adapter configs do not populate legacy `Settings`.
- Mock/CLI/Ollama/VLLM configs each use typed oneof fields.
- Node legacy `settings` fallback behavior remains covered and compatible.
- Generated Go and Dart proto files are in sync with `proto/iop/runtime.proto`.
- Verification output is pasted verbatim.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```bash
$ make proto
(output)
```
```bash
$ make proto-dart
(output)
```
```bash
$ go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/node/internal/adapters ./proto/gen/...
(output)
```
### 최종 검증
```bash
$ make proto
(output)
```
```bash
$ make proto-dart
(output)
```
```bash
$ go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/node/internal/adapters ./proto/gen/...
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,166 @@
<!-- task=m-bridge-boundary-hardening/03_adapter_config_typed plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
이 작업은 구현 후 active 파일을 유지하고, 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운 뒤 리뷰 준비 상태로 멈춘다. 구현 중 사용자 결정, 사용자 소유 외부 환경, 또는 계획 범위 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 남기고 중단한다. 구현 중 사용자에게 직접 질문하거나 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다.
## 배경
Milestone의 `adapter-config-typed` Task는 `AdapterConfig.settings`를 legacy fallback으로만 남기고, Edge가 새 adapter config를 typed oneof/proto 메시지로만 생성한다는 계약을 고정하는 작업이다. 현재 CLI/Ollama/VLLM은 typed config를 사용하지만 Edge mapper의 mock adapter payload는 `structpb.NewStruct(nil)``settings`를 채워 신규 payload에서도 legacy 필드를 사용한다. 이 plan은 mock까지 typed oneof로 옮기고, 기존 Node legacy fallback은 호환 경로로 유지한다.
## 사용자 리뷰 요청 흐름
구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 생성을 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- Task ids:
- `adapter-config-typed`: `AdapterConfig.settings`는 구버전 Edge payload fallback으로만 허용하고, Edge mapper와 신규 config는 typed oneof/proto 메시지를 사용한다는 guard를 테스트로 고정한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/node-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-test/local/testing-smoke.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `proto/iop/runtime.proto`
- `apps/edge/internal/node/mapper.go`
- `apps/edge/internal/node/mapper_test.go`
- `apps/edge/internal/transport/server_test.go`
- `apps/node/internal/adapters/factory.go`
- `apps/node/internal/adapters/factory_internal_test.go`
- `apps/node/internal/adapters/adapters_blackbox_test.go`
- `apps/node/internal/bootstrap/module_test.go`
- `apps/node/internal/transport/integration_test.go`
- `Makefile`
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md`를 읽었고, domain routing에 따라 `edge-smoke`, `node-smoke`, `platform-common-smoke`, 사용자 실행 파이프라인 영향 기준으로 `testing-smoke`를 읽었다.
- 적용 명령: `make proto && make proto-dart && go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/node/internal/adapters ./proto/gen/...`.
- 사전 확인 결과: `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/node/internal/adapters ./proto/gen/...`는 현재 checkout에서 성공했다.
- 도구 확인 결과: `protoc`, `protoc-gen-go`, `protoc-gen-dart`가 현재 환경에 있다.
- Milestone 검증 명령은 `make proto && go test ...`이지만, proto 변경 시 tracked Dart binding drift를 막기 위해 `make proto-dart`도 최종 검증에 포함한다. 구현자가 Dart generation을 생략하면 이유와 남은 위험을 review stub에 기록한다.
### 테스트 커버리지 공백
- `apps/edge/internal/node/mapper_test.go`는 CLI/Ollama/VLLM typed payload를 검증한다.
- `apps/edge/internal/transport/server_test.go`는 Edge transport payload의 adapter config를 검증한다.
- `apps/node/internal/adapters/factory_internal_test.go``apps/node/internal/bootstrap/module_test.go`는 legacy `settings` fallback 경로를 보유한다.
- 현재 공백: Edge가 생성하는 mock adapter config에서 `settings == nil`이고 typed mock config가 설정된다는 guard가 없다.
### 심볼 참조
- 변경 예정: `AdapterConfig` oneof, `BuildConfigPayload`, Edge mapper/transport tests.
- 유지 예정: Node `BuildFromPayload`의 legacy `settings` fallback, bootstrap legacy regression tests.
- 현재 legacy 생성 지점: `apps/edge/internal/node/mapper.go`의 mock adapter branch가 `structpb.NewStruct(nil)``Settings`에 채운다.
### 분할 판단
- split decision policy를 적용했다.
- 공유 task group: `m-bridge-boundary-hardening`.
- sibling subtasks:
- `01_edge_run_surface`: Edge run surface. 독립.
- `02_terminal_core_boundary`: Node terminal package/interface boundary. 독립.
- `03_adapter_config_typed`: typed adapter config contract. 독립.
- 이 subtask는 다른 sibling의 `complete.log`를 필요로 하지 않는다.
### 범위 결정 근거
- legacy `AdapterConfig.settings` fallback 즉시 제거는 Milestone 범위 제외이므로 제거하지 않는다.
- adapter execution behavior, provider defaults, terminal bridge protocol은 변경하지 않는다.
- proto field number는 새 oneof field를 추가해 호환성을 유지하고 기존 `settings = 3`, `cli = 4`, `ollama = 5`, `vllm = 6` 번호는 재사용하지 않는다.
### 빌드 등급
- `cloud-G06`: proto 계약과 Edge/Node 양쪽 테스트를 함께 만지지만 동작 변경은 mock config payload guard에 제한되고 실행 경로는 deterministic하다.
## 구현 체크리스트
- [ ] Mock adapter config를 typed oneof/proto 메시지로 추가하고 generated Go/Dart 바인딩을 동기화한다.
- [ ] Edge mapper와 transport tests가 신규 Edge-generated payload에서 `settings`를 사용하지 않음을 검증하게 한다.
- [ ] Node legacy fallback tests는 유지하고, 필요한 경우 typed mock config가 기존 mock adapter와 호환됨을 검증한다.
- [ ] 사용자 실행 파이프라인 영향에 대해 보조 smoke/full-cycle 수행 여부 또는 생략 사유를 검증 결과에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Typed Mock Adapter Config
문제: 신규 Edge mapper payload가 mock adapter에 대해 legacy `settings`를 사용한다.
Before:
```go
// apps/edge/internal/node/mapper.go
settings, err := structpb.NewStruct(nil)
if err != nil {
return nil, err
}
config = &runtimev1.AdapterConfig{Adapter: adapter, Settings: settings}
```
해결 방법: `proto/iop/runtime.proto`에 empty `MockAdapterConfig` message를 추가하고 `AdapterConfig.config` oneof에 새 field로 연결한다. Edge mapper는 mock adapter에 `AdapterConfig_Mock`을 채우며 `Settings`는 nil로 둔다. Node adapter factory는 mock config를 명시적으로 요구하지 않는 현재 동작을 유지하되, typed mock config가 들어와도 실패하지 않도록 필요한 테스트를 보강한다.
수정 파일 및 체크리스트:
- [ ] `proto/iop/runtime.proto`: `MockAdapterConfig` message와 `AdapterConfig.mock` oneof field 추가.
- [ ] `proto/gen/go/iop/runtime/v1/runtime.pb.go`: `make proto`로 재생성.
- [ ] `apps/client/lib/gen/iop/runtime/v1/runtime.pb.dart`: `make proto-dart`로 재생성.
- [ ] `apps/client/lib/gen/iop/runtime/v1/runtime.pbenum.dart`: 필요 시 `make proto-dart`로 재생성.
- [ ] `apps/client/lib/gen/iop/runtime/v1/runtime.pbjson.dart`: 필요 시 `make proto-dart`로 재생성.
- [ ] `apps/edge/internal/node/mapper.go`: mock branch에서 typed mock config 사용, `structpb` import 제거.
- [ ] `apps/edge/internal/node/mapper_test.go`: mock/cli/ollama/vllm Edge-generated config 모두 `Settings == nil`이고 적절한 typed config를 갖는다는 guard 추가.
- [ ] `apps/edge/internal/transport/server_test.go`: transport payload 검증에서 legacy settings가 신규 payload에 섞이지 않음을 확인.
- [ ] `apps/node/internal/adapters/factory_internal_test.go`: 기존 legacy fallback tests 유지, typed mock config 호환 guard가 필요하면 추가.
테스트 작성: 기존 mapper/transport/factory test 파일 안에 case를 추가한다. 새 테스트 파일은 만들지 않는다.
중간 검증:
```bash
make proto
make proto-dart
go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/node/internal/adapters ./proto/gen/...
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `proto/iop/runtime.proto` | API-1 |
| `proto/gen/go/iop/runtime/v1/runtime.pb.go` | API-1 |
| `apps/client/lib/gen/iop/runtime/v1/runtime.pb.dart` | API-1 |
| `apps/client/lib/gen/iop/runtime/v1/runtime.pbenum.dart` | API-1 |
| `apps/client/lib/gen/iop/runtime/v1/runtime.pbjson.dart` | API-1 |
| `apps/edge/internal/node/mapper.go` | API-1 |
| `apps/edge/internal/node/mapper_test.go` | API-1 |
| `apps/edge/internal/transport/server_test.go` | API-1 |
| `apps/node/internal/adapters/factory_internal_test.go` | API-1 |
## 최종 검증
```bash
make proto
make proto-dart
go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/transport ./apps/node/internal/adapters ./proto/gen/...
```
예상 결과: proto generation succeeds without tracked drift, listed Go packages all pass.
사용자 실행 파이프라인 영향 기록: config/proto contract가 runtime startup payload에 닿으므로 보조 smoke/full-cycle 실제 구동을 수행했으면 명령과 출력을 붙이고, 수행하지 못했으면 `testing-smoke` 기준으로 사유와 남은 위험을 `CODE_REVIEW`에 기록한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -31,16 +31,20 @@ class _ClientHomePageState extends State<ClientHomePage> {
String _wireStatus = 'Disconnected';
ClientWireClient? _client;
StreamSubscription? _notificationSubscription;
HttpControlPlaneStatusRepository? _ownedStatusRepository;
late final ControlPlaneStatusController _controller;
@override
void initState() {
super.initState();
final statusRepo = widget.statusRepository ??
HttpControlPlaneStatusRepository(
baseUrl: ClientConfig.controlPlaneHttpUrl,
);
final ownedStatusRepository = widget.statusRepository == null
? HttpControlPlaneStatusRepository(
baseUrl: ClientConfig.controlPlaneHttpUrl,
)
: null;
_ownedStatusRepository = ownedStatusRepository;
final statusRepo = widget.statusRepository ?? ownedStatusRepository!;
_controller = ControlPlaneStatusController(statusRepository: statusRepo);
// Auto-connect on start
@ -62,6 +66,7 @@ class _ClientHomePageState extends State<ClientHomePage> {
_notificationSubscription?.cancel();
_client?.close();
_controller.dispose();
_ownedStatusRepository?.close();
super.dispose();
}
@ -85,10 +90,7 @@ class _ClientHomePageState extends State<ClientHomePage> {
}
Future<void> _connectAndFetch() async {
await Future.wait([
_connectWire(),
_controller.fetchEdges(),
]);
await Future.wait([_connectWire(), _controller.fetchEdges()]);
}
Future<void> _connectWire() async {
@ -166,12 +168,19 @@ class _ClientHomePageState extends State<ClientHomePage> {
nodes: NodesPanel(
edges: _controller.edges,
selectedEdgeId: currentEdgeId,
selectedEdgeStatus: currentEdgeId != null ? _controller.edgeStatuses[currentEdgeId] : null,
isLoading: currentEdgeId != null ? (_controller.loadingStatuses[currentEdgeId] ?? false) : false,
error: currentEdgeId != null ? _controller.statusErrors[currentEdgeId] : null,
selectedEdgeStatus: currentEdgeId != null
? _controller.edgeStatuses[currentEdgeId]
: null,
isLoading: currentEdgeId != null
? (_controller.loadingStatuses[currentEdgeId] ?? false)
: false,
error: currentEdgeId != null
? _controller.statusErrors[currentEdgeId]
: null,
onSelectEdge: _controller.selectEdge,
onRefresh: () {
if (currentEdgeId != null) _controller.fetchEdgeStatus(currentEdgeId);
if (currentEdgeId != null)
_controller.fetchEdgeStatus(currentEdgeId);
},
),
runtime: RuntimePanel(
@ -183,12 +192,19 @@ class _ClientHomePageState extends State<ClientHomePage> {
executionLogs: ExecutionLogsPanel(
edges: _controller.edges,
selectedEdgeId: currentEdgeId,
events: currentEdgeId != null ? (_controller.edgeEvents[currentEdgeId] ?? []) : [],
isLoading: currentEdgeId != null ? (_controller.loadingEvents[currentEdgeId] ?? false) : false,
error: currentEdgeId != null ? _controller.eventErrors[currentEdgeId] : null,
events: currentEdgeId != null
? (_controller.edgeEvents[currentEdgeId] ?? [])
: [],
isLoading: currentEdgeId != null
? (_controller.loadingEvents[currentEdgeId] ?? false)
: false,
error: currentEdgeId != null
? _controller.eventErrors[currentEdgeId]
: null,
onSelectEdge: _controller.selectEdge,
onRefresh: () {
if (currentEdgeId != null) _controller.fetchEdgeEvents(currentEdgeId);
if (currentEdgeId != null)
_controller.fetchEdgeEvents(currentEdgeId);
},
),
agent: const IopAgentPanel(capabilities: iopDefaultCapabilityPack),

View file

@ -18,11 +18,20 @@ abstract class ControlPlaneStatusRepository {
class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository {
final String baseUrl;
final http.Client? client;
final http.Client _client;
final bool _ownsClient;
HttpControlPlaneStatusRepository({required this.baseUrl, this.client});
HttpControlPlaneStatusRepository({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client(),
_ownsClient = client == null;
http.Client get _httpClient => client ?? http.Client();
http.Client get _httpClient => _client;
void close() {
if (_ownsClient) {
_client.close();
}
}
@override
Future<List<EdgeRegistryView>> fetchEdges() async {
@ -32,14 +41,20 @@ class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository {
}
final body = json.decode(response.body) as Map<String, dynamic>;
final list = body['edges'] as List? ?? [];
return list.map((e) => EdgeRegistryView.fromJson(e as Map<String, dynamic>)).toList();
return list
.map((e) => EdgeRegistryView.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<EdgeStatusResponseView> fetchEdgeStatus(String edgeId) async {
final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/status'));
final response = await _httpClient.get(
Uri.parse('$baseUrl/edges/$edgeId/status'),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch status for $edgeId: ${response.statusCode}');
throw Exception(
'Failed to fetch status for $edgeId: ${response.statusCode}',
);
}
final body = json.decode(response.body) as Map<String, dynamic>;
return EdgeStatusResponseView.fromJson(body);
@ -47,13 +62,19 @@ class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository {
@override
Future<List<EdgeNodeEventView>> fetchEdgeEvents(String edgeId) async {
final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/events'));
final response = await _httpClient.get(
Uri.parse('$baseUrl/edges/$edgeId/events'),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch events for $edgeId: ${response.statusCode}');
throw Exception(
'Failed to fetch events for $edgeId: ${response.statusCode}',
);
}
final body = json.decode(response.body) as Map<String, dynamic>;
final list = body['events'] as List? ?? [];
return list.map((e) => EdgeNodeEventView.fromJson(e as Map<String, dynamic>)).toList();
return list
.map((e) => EdgeNodeEventView.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
@ -68,9 +89,13 @@ class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository {
@override
Future<EdgeOperationsResponseView> fetchEdgeOperations(String edgeId) async {
final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/operations'));
final response = await _httpClient.get(
Uri.parse('$baseUrl/edges/$edgeId/operations'),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch operations for $edgeId: ${response.statusCode}');
throw Exception(
'Failed to fetch operations for $edgeId: ${response.statusCode}',
);
}
final body = json.decode(response.body) as Map<String, dynamic>;
return EdgeOperationsResponseView.fromJson(body);
@ -83,9 +108,7 @@ class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository {
String? targetSelector,
Map<String, String>? parameters,
}) async {
final Map<String, dynamic> requestBody = {
'operation': operation,
};
final Map<String, dynamic> requestBody = {'operation': operation};
if (targetSelector != null) {
requestBody['target_selector'] = targetSelector;
}
@ -99,7 +122,9 @@ class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository {
body: json.encode(requestBody),
);
if (response.statusCode != 200 && response.statusCode != 202) {
throw Exception('Failed to send command to $edgeId: ${response.statusCode}');
throw Exception(
'Failed to send command to $edgeId: ${response.statusCode}',
);
}
final body = json.decode(response.body) as Map<String, dynamic>;
return EdgeCommandResponseView.fromJson(body);

View file

@ -1,9 +1,37 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_test/flutter_test.dart';
import 'package:iop_client/control_plane_status_controller.dart';
import 'package:iop_client/control_plane_status_client.dart';
import 'widget_test.dart';
class TrackingHttpClient extends http.BaseClient {
int requestCount = 0;
int closeCount = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
requestCount += 1;
final response = http.Response(
json.encode({'edges': <Map<String, dynamic>>[]}),
200,
);
return http.StreamedResponse(
Stream<List<int>>.fromIterable([response.bodyBytes]),
response.statusCode,
headers: response.headers,
request: request,
);
}
@override
void close() {
closeCount += 1;
super.close();
}
}
class ControllableStatusRepository implements ControlPlaneStatusRepository {
final fleetCompleter = Completer<FleetStatusResponseView>();
final statusCompleter = Completer<EdgeStatusResponseView>();
@ -58,6 +86,20 @@ class ControllableStatusRepository implements ControlPlaneStatusRepository {
}
void main() {
test('HTTP repository keeps injected client caller-owned', () async {
final client = TrackingHttpClient();
final repository = HttpControlPlaneStatusRepository(
baseUrl: 'http://control-plane.test',
client: client,
);
await repository.fetchEdges();
repository.close();
expect(client.requestCount, 1);
expect(client.closeCount, 0);
});
test(
'removes stale selected edge caches when fleet refresh drops edge',
() async {

View file

@ -17,7 +17,7 @@ import (
)
type runService interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, error)
CancelRun(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
}
@ -182,7 +182,7 @@ func (s *Server) handleMessageSend(w http.ResponseWriter, r *http.Request, rpcRe
return
}
taskID := s.tasks.create(handle.RunID, params.Message)
taskID := s.tasks.create(handle.Dispatch().RunID, params.Message)
var snapshot *Task
if blocking {

View file

@ -18,11 +18,11 @@ import (
// fakeService is a minimal runService implementation for testing.
type fakeService struct {
submitFn func(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
submitFn func(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, error)
cancelFn func(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
}
func (f *fakeService) SubmitRun(ctx context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
func (f *fakeService) SubmitRun(ctx context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
if f.submitFn != nil {
return f.submitFn(ctx, req)
}
@ -43,7 +43,7 @@ func newTestServer(cfg config.EdgeA2AConf, svc runServiceIface) *a2a.Server {
}
type runServiceIface interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, error)
CancelRun(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
}
@ -72,7 +72,7 @@ func decodeRPCResponse(t *testing.T, w *httptest.ResponseRecorder) a2a.JSONRPCRe
func TestHandleMessageSendDispatchesRun(t *testing.T) {
dispatched := false
svc := &fakeService{
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
dispatched = true
if req.Metadata["source"] != "a2a" {
t.Errorf("expected source=a2a, got %q", req.Metadata["source"])
@ -115,7 +115,7 @@ func TestHandleMessageSendDispatchesRun(t *testing.T) {
func TestHandleGetTaskReturnsStoredTask(t *testing.T) {
runID := "run-get-1"
svc := &fakeService{
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
return feedHandle(runID,
&iop.RunEvent{RunId: runID, Type: "delta", Delta: "result text"},
&iop.RunEvent{RunId: runID, Type: "complete"},
@ -187,7 +187,7 @@ func TestHandleCancelTask(t *testing.T) {
runID := "run-cancel-1"
cancelCalled := false
svc := &fakeService{
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
// Return a handle that stays working (never completes) for non-blocking.
events := make(chan *iop.RunEvent, 1)
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
@ -316,7 +316,7 @@ func TestBlockingDefault(t *testing.T) {
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
var dispatched bool
svc := &fakeService{
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
dispatched = true
got := req.Metadata["blocking"]
want := "true"

View file

@ -79,32 +79,33 @@ func (s *TaskStore) create(id string, userMsg Message) string {
return id
}
// collectBackground drains a RunHandle in a background goroutine and updates the stored Task.
func (s *TaskStore) collectBackground(taskID string, handle *edgeservice.RunHandle) {
// collectBackground drains a RunResult in a background goroutine and updates the stored Task.
func (s *TaskStore) collectBackground(taskID string, handle edgeservice.RunResult) {
go func() {
defer handle.Close()
s.drain(taskID, handle)
}()
}
// collectBlocking drains a RunHandle synchronously and returns the final Task snapshot.
func (s *TaskStore) collectBlocking(taskID string, handle *edgeservice.RunHandle) *Task {
// collectBlocking drains a RunResult synchronously and returns the final Task snapshot.
func (s *TaskStore) collectBlocking(taskID string, handle edgeservice.RunResult) *Task {
defer handle.Close()
return s.drain(taskID, handle)
}
func (s *TaskStore) drain(taskID string, handle *edgeservice.RunHandle) *Task {
func (s *TaskStore) drain(taskID string, handle edgeservice.RunResult) *Task {
var text strings.Builder
timeout := time.NewTimer(handle.WaitTimeout())
defer timeout.Stop()
stream := handle.Stream()
for {
select {
case nodeEvent := <-handle.NodeEvents:
case nodeEvent := <-stream.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
return s.setFailed(taskID, "node disconnected")
}
case event := <-handle.Events:
case event := <-stream.Events:
if event == nil {
continue
}

View file

@ -26,10 +26,10 @@ func feedHandle(runID string, events ...*iop.RunEvent) *edgeservice.RunHandle {
}
}
func checkBlockingResult(t *testing.T, handle *edgeservice.RunHandle, check func(map[string]any)) {
func checkBlockingResult(t *testing.T, handle edgeservice.RunResult, check func(map[string]any)) {
t.Helper()
svc := &fakeService{
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
return handle, nil
},
}
@ -134,7 +134,7 @@ func TestCancelTaskSendsCancelRun(t *testing.T) {
}
svc := &fakeService{
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
return handle, nil
},
cancelFn: func(_ context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) {

View file

@ -86,15 +86,15 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
s.completeChatCompletion(w, r, req, handle, outputPolicy)
}
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
text, reasoning, usage, err := collectRunResult(r.Context(), handle)
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
text, reasoning, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
if err != nil {
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
return
}
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion output",
zap.String("run_id", handle.RunID),
zap.String("run_id", handle.Dispatch().RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
@ -105,10 +105,10 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request,
)
created := time.Now().Unix()
writeJSON(w, http.StatusOK, chatCompletionResponse{
ID: "chatcmpl-" + handle.RunID,
ID: "chatcmpl-" + handle.Dispatch().RunID,
Object: "chat.completion",
Created: created,
Model: responseModel(req.Model, handle.Target),
Model: responseModel(req.Model, handle.Dispatch().Target),
Choices: []chatCompletionChoice{{
Index: 0,
Message: chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning},

View file

@ -9,11 +9,11 @@ import (
edgeservice "iop/apps/edge/internal/service"
)
func collectRunResult(ctx context.Context, handle *edgeservice.RunHandle) (string, string, *openAIUsage, error) {
func collectRunResult(ctx context.Context, stream edgeservice.RunStream, timeout time.Duration) (string, string, *openAIUsage, error) {
var contentBuilder strings.Builder
var reasoningBuilder strings.Builder
var usage *openAIUsage
timer := time.NewTimer(handle.WaitTimeout())
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
@ -21,11 +21,11 @@ func collectRunResult(ctx context.Context, handle *edgeservice.RunHandle) (strin
return "", "", nil, ctx.Err()
case <-timer.C:
return "", "", nil, fmt.Errorf("run timed out")
case nodeEvent := <-handle.NodeEvents:
case nodeEvent := <-stream.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
return "", "", nil, fmt.Errorf("node disconnected")
}
case event := <-handle.Events:
case event := <-stream.Events:
if event == nil {
continue
}

View file

@ -15,7 +15,7 @@ import (
)
type runService interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, error)
OllamaAPI(context.Context, edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error)
}

View file

@ -21,7 +21,7 @@ type fakeRunService struct {
events chan *iop.RunEvent
}
func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
s.req = req
return &edgeservice.RunHandle{
RunDispatch: edgeservice.RunDispatch{
@ -413,7 +413,7 @@ func TestCollectRunResultTimesOut(t *testing.T) {
}
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
_, _, _, err := collectRunResult(ctx, handle)
_, _, _, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout())
if err == nil {
t.Fatal("expected timeout error")
}

View file

@ -12,7 +12,7 @@ import (
edgeservice "iop/apps/edge/internal/service"
)
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle, outputPolicy strictOutputPolicy) {
func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming")
@ -23,8 +23,8 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
w.Header().Set("Connection", "keep-alive")
created := time.Now().Unix()
id := "chatcmpl-" + handle.RunID
model := responseModel(req.Model, handle.Target)
id := "chatcmpl-" + handle.Dispatch().RunID
model := responseModel(req.Model, handle.Dispatch().Target)
writeSSE(w, flusher, chatCompletionChunk{
ID: id,
Object: "chat.completion.chunk",
@ -45,7 +45,7 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
var reasoningBuilder strings.Builder
defer func() {
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.String("run_id", handle.Dispatch().RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.Int("content_len", contentBuilder.Len()),
@ -55,16 +55,17 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
)
}()
stream := handle.Stream()
for {
select {
case <-r.Context().Done():
return
case nodeEvent := <-handle.NodeEvents:
case nodeEvent := <-stream.NodeEvents:
if edgeservice.IsNodeDisconnected(nodeEvent) {
writeSSEError(w, flusher, "node disconnected")
return
}
case event := <-handle.Events:
case event := <-stream.Events:
if event == nil {
continue
}
@ -135,15 +136,15 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
}
}
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle *edgeservice.RunHandle, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy) {
text, reasoning, _, err := collectRunResult(r.Context(), handle)
func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle edgeservice.RunResult, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy) {
text, reasoning, _, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
if err != nil {
writeSSEError(w, flusher, err.Error())
return
}
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
s.logger.Info("openai chat completion stream closed",
zap.String("run_id", handle.RunID),
zap.String("run_id", handle.Dispatch().RunID),
zap.Bool("strict_output", outputPolicy.Strict),
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),

View file

@ -245,8 +245,9 @@ func SendRun(ctx context.Context, edgeSvc *edgeservice.Service, events *EventRou
}
defer handle.Close()
dispatch := handle.Dispatch()
fmt.Fprintf(out, "[edge] sent run_id=%s node=%s adapter=%s target=%s session=%s background=%v\n",
handle.RunID, handle.NodeLabel, handle.Adapter, handle.Target, handle.SessionID, handle.Background)
dispatch.RunID, dispatch.NodeLabel, dispatch.Adapter, dispatch.Target, dispatch.SessionID, dispatch.Background)
if target.Background {
fmt.Fprintf(out, "[edge] background run dispatched, events will arrive asynchronously\n")
@ -257,6 +258,7 @@ func SendRun(ctx context.Context, edgeSvc *edgeservice.Service, events *EventRou
defer timer.Stop()
var response *ResponseStream
stream := handle.Stream()
for {
select {
@ -264,12 +266,12 @@ func SendRun(ctx context.Context, edgeSvc *edgeservice.Service, events *EventRou
return ctx.Err()
case <-timer.C:
return fmt.Errorf("timed out waiting for node response")
case event := <-handle.NodeEvents:
case event := <-stream.NodeEvents:
if edgeservice.IsNodeDisconnected(event) {
return fmt.Errorf("node disconnected: %s", event.GetReason())
}
case event := <-handle.Events:
if event.GetRunId() != handle.RunID {
case event := <-stream.Events:
if event.GetRunId() != dispatch.RunID {
continue
}
label := events.NodeLabel(event)
@ -279,23 +281,23 @@ func SendRun(ctx context.Context, edgeSvc *edgeservice.Service, events *EventRou
switch event.GetType() {
case "start":
fmt.Fprintf(out, "%s start run_id=%s\n", nodeEventPrefix(label), handle.RunID)
fmt.Fprintf(out, "%s start run_id=%s\n", nodeEventPrefix(label), dispatch.RunID)
case "delta":
response.Write(event.GetDelta())
case "complete":
response.Finish()
fmt.Fprintf(out, "%s complete run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetMessage())
fmt.Fprintf(out, "%s complete run_id=%s detail=%q\n", nodeEventPrefix(label), dispatch.RunID, event.GetMessage())
return nil
case "cancelled":
response.FinishIfStarted()
fmt.Fprintf(out, "%s cancelled run_id=%s\n", nodeEventPrefix(label), handle.RunID)
fmt.Fprintf(out, "%s cancelled run_id=%s\n", nodeEventPrefix(label), dispatch.RunID)
return nil
case "error":
response.FinishIfStarted()
fmt.Fprintf(out, "%s error run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetError())
fmt.Fprintf(out, "%s error run_id=%s detail=%q\n", nodeEventPrefix(label), dispatch.RunID, event.GetError())
return fmt.Errorf("node reported error")
default:
fmt.Fprintf(out, "%s %s run_id=%s detail=%q\n", nodeEventPrefix(label), event.GetType(), handle.RunID, event.GetMessage())
fmt.Fprintf(out, "%s %s run_id=%s detail=%q\n", nodeEventPrefix(label), event.GetType(), dispatch.RunID, event.GetMessage())
}
}
}

View file

@ -44,6 +44,15 @@ type RunStream struct {
NodeEvents <-chan *iop.EdgeNodeEvent
}
// RunResult describes the interface for a submitted execution run, allowing
// surface-neutral consumption of its dispatch info and event stream.
type RunResult interface {
Dispatch() RunDispatch
Stream() RunStream
Close()
WaitTimeout() time.Duration
}
// RunHandle is the legacy combined surface kept for existing callers; it
// embeds the surface-neutral DTOs so HTTP/API code can consume RunDispatch
// or RunStream directly without depending on this struct.
@ -66,7 +75,21 @@ func (h *RunHandle) WaitTimeout() time.Duration {
return time.Duration(normalizeTimeoutSec(h.TimeoutSec)+5) * time.Second
}
func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (*RunHandle, error) {
func (h *RunHandle) Dispatch() RunDispatch {
if h == nil {
return RunDispatch{}
}
return h.RunDispatch
}
func (h *RunHandle) Stream() RunStream {
if h == nil {
return RunStream{}
}
return h.RunStream
}
func (s *Service) SubmitRun(_ context.Context, req SubmitRunRequest) (RunResult, error) {
entry, err := s.ResolveNode(req.NodeRef)
if err != nil {
return nil, err