From 0941d572e553203d3f9f2a94a0bff6801ea395cd Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 11 Jun 2026 09:12:28 +0900 Subject: [PATCH] feat: agent registration capability boundary implementation - Implement command adapter for G05 - Wire registration components for G06 - Add runner_command_capability_provider - Update agent_runner and registration_client - Update tests for registration and command catalog --- .../code_review_local_G05_0.log | 101 +++ .../02+01_command_adapter/complete.log | 43 + .../plan_local_G05_0.log} | 0 .../code_review_local_G06_0.log | 125 +++ .../03+01,02_registration_wiring/complete.log | 45 + .../plan_local_G06_0.log} | 0 .../CODE_REVIEW-local-G05.md | 78 -- .../CODE_REVIEW-local-G06.md | 86 -- apps/runner/lib/oto/agent/agent_runner.dart | 20 +- .../lib/oto/agent/registration_client.dart | 24 +- .../runner_command_capability_provider.dart | 24 + .../test/oto_agent_registration_test.dart | 858 ++++++++++-------- .../runner/test/oto_command_catalog_test.dart | 405 +++++---- 13 files changed, 1097 insertions(+), 712 deletions(-) create mode 100644 agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/code_review_local_G05_0.log create mode 100644 agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/complete.log rename agent-task/{m-agent-registration-capability-boundary/02+01_command_adapter/PLAN-local-G05.md => archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/plan_local_G05_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/code_review_local_G06_0.log create mode 100644 agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/complete.log rename agent-task/{m-agent-registration-capability-boundary/03+01,02_registration_wiring/PLAN-local-G06.md => archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/plan_local_G06_0.log} (100%) delete mode 100644 agent-task/m-agent-registration-capability-boundary/02+01_command_adapter/CODE_REVIEW-local-G05.md delete mode 100644 agent-task/m-agent-registration-capability-boundary/03+01,02_registration_wiring/CODE_REVIEW-local-G06.md create mode 100644 apps/runner/lib/oto/commands/runner_command_capability_provider.dart diff --git a/agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/code_review_local_G05_0.log b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/code_review_local_G05_0.log new file mode 100644 index 0000000..f3371bc --- /dev/null +++ b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/code_review_local_G05_0.log @@ -0,0 +1,101 @@ + + +# Code Review Reference - REFACTOR + +## 개요 + +date=2026-06-10 +task=m-agent-registration-capability-boundary/02+01_command_adapter, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/message-based-build-agent/milestones/agent-registration-capability-boundary.md` +- Task ids: + - `command-adapter`: command catalog 조회와 `registerAllCommands()` 호출을 command domain adapter 뒤로 이동한다. +- Completion mode: check-on-pass + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Command Adapter | [x] | + +## 구현 체크리스트 + +- [x] 선행 `agent-task/m-agent-registration-capability-boundary/01_agent_port/**/complete.log` 존재를 확인한다. +- [x] command domain에 agent port 구현 adapter를 추가한다. +- [x] adapter가 `registerAllCommands()` 호출 후 `Command.catalogRows`에서 type만 안정적으로 산출하게 한다. +- [x] command catalog test에 adapter command type parity test를 추가한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +- [x] PASS/WARN/FAIL 판정을 append한다. +- [x] active plan/review 파일을 `.log`로 아카이브한다. +- [x] PASS이면 `complete.log` 작성 후 archive로 이동한다. + +## 계획 대비 변경 사항 + +- production default wiring은 계획 범위 밖인 `03` 작업으로 남기고 변경하지 않았다. +- adapter의 기본 name/version은 command domain 파일에서 literal default로 보유하되, 테스트에서 `OtoServerRegistrationClient`의 현재 public constants와 일치하는지 검증했다. command adapter가 registration client 구현 파일에 직접 의존하지 않게 하기 위한 선택이다. + +## 주요 설계 결정 + +- `CommandCatalogRunnerCapabilityProvider`를 `apps/runner/lib/oto/commands/runner_command_capability_provider.dart`에 추가해 command domain이 `RunnerCapabilityProvider` port를 구현하게 했다. +- `snapshot()` 호출 시 `registerAllCommands()`를 먼저 실행하고, `Command.catalogRows.map((row) => row['type']!)`를 `RunnerCapabilitySnapshot.commandTypes`로 전달한다. +- command catalog test에 adapter parity test를 추가해 adapter command type 목록이 catalog type 목록과 동일하고 capability name/version이 등록 client constants와 일치하는지 검증했다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- adapter가 command 도메인에 있고 agent implementation 파일에 command import를 추가하지 않았는지 확인 +- `registerAllCommands()` 호출이 adapter 뒤로 이동했는지 확인 +- test가 command catalog와 adapter parity를 검증하는지 확인 + +## 검증 결과 + +### REFACTOR-1 중간 검증 + +```text +$ cd apps/runner && dart test test/oto_command_catalog_test.dart ++21: All tests passed! +``` + +### 최종 검증 + +```text +$ cd apps/runner && dart analyze +No issues found! + +$ cd apps/runner && dart test test/oto_command_catalog_test.dart test/oto_command_runtime_test.dart ++48: All tests passed! +``` + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 리뷰 중 정리: + - `dart format apps/runner/lib/oto/commands/runner_command_capability_provider.dart apps/runner/test/oto_command_catalog_test.dart`로 포맷 drift를 정리했다. +- 검증: + - `cd apps/runner && dart analyze` - PASS; `No issues found!` + - `cd apps/runner && dart test test/oto_command_catalog_test.dart test/oto_command_runtime_test.dart` - PASS; `+48: All tests passed!` + - `dart format --output=none --set-exit-if-changed apps/runner/lib/oto/commands/runner_command_capability_provider.dart apps/runner/test/oto_command_catalog_test.dart` - PASS; `Formatted 2 files (0 changed)` +- 다음 단계: PASS이므로 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/complete.log b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/complete.log new file mode 100644 index 0000000..d7c8992 --- /dev/null +++ b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/complete.log @@ -0,0 +1,43 @@ +# Complete - m-agent-registration-capability-boundary/02+01_command_adapter + +## 완료 일시 + +2026-06-10T23:30:38Z + +## 요약 + +Command domain adapter가 `RunnerCapabilityProvider` port를 구현하고 command catalog type parity test를 추가한 작업을 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | command adapter, catalog parity test, local command smoke 검증이 계획 범위와 검증 기준을 충족했다. | + +## 구현/정리 내용 + +- `CommandCatalogRunnerCapabilityProvider`를 command domain에 추가해 `RunnerCapabilityProvider` snapshot을 command catalog 기반으로 생성하게 했다. +- `snapshot()`에서 `registerAllCommands()` 호출 후 `Command.catalogRows`의 `type` 목록을 immutable capability snapshot으로 전달하게 했다. +- command catalog test에 adapter output과 catalog type 목록, capability name/version parity 검증을 추가했다. +- 리뷰 중 Dart formatter로 변경 파일의 포맷 drift를 정리했다. + +## 최종 검증 + +- `cd apps/runner && dart analyze` - PASS; `No issues found!` +- `cd apps/runner && dart test test/oto_command_catalog_test.dart test/oto_command_runtime_test.dart` - PASS; `+48: All tests passed!` +- `dart format --output=none --set-exit-if-changed apps/runner/lib/oto/commands/runner_command_capability_provider.dart apps/runner/test/oto_command_catalog_test.dart` - PASS; `Formatted 2 files (0 changed)` + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/message-based-build-agent/milestones/agent-registration-capability-boundary.md` +- Completed task ids: + - `command-adapter`: PASS; evidence=`plan_local_G05_0.log`, `code_review_local_G05_0.log`; verification=`cd apps/runner && dart analyze`, `cd apps/runner && dart test test/oto_command_catalog_test.dart test/oto_command_runtime_test.dart`, `dart format --output=none --set-exit-if-changed apps/runner/lib/oto/commands/runner_command_capability_provider.dart apps/runner/test/oto_command_catalog_test.dart` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-agent-registration-capability-boundary/02+01_command_adapter/PLAN-local-G05.md b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/plan_local_G05_0.log similarity index 100% rename from agent-task/m-agent-registration-capability-boundary/02+01_command_adapter/PLAN-local-G05.md rename to agent-task/archive/2026/06/m-agent-registration-capability-boundary/02+01_command_adapter/plan_local_G05_0.log diff --git a/agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/code_review_local_G06_0.log b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/code_review_local_G06_0.log new file mode 100644 index 0000000..2f08e9c --- /dev/null +++ b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/code_review_local_G06_0.log @@ -0,0 +1,125 @@ + + +# Code Review Reference - REFACTOR + +## 개요 + +date=2026-06-10 +task=m-agent-registration-capability-boundary/03+01,02_registration_wiring, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/message-based-build-agent/milestones/agent-registration-capability-boundary.md` +- Task ids: + - `registration`: 기존 registration payload 의미를 유지하면서 agent registration client가 adapter 계약만 소비하도록 바꾼다. + - `rule-evidence`: 변경 후 agent domain rule 위반 import가 남아 있지 않은지 확인한다. +- Completion mode: check-on-pass + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Registration Wiring | [x] | + +## 구현 체크리스트 + +- [x] 선행 `01_agent_port`, `02+01_command_adapter` complete evidence를 확인한다. + - `apps/runner/lib/oto/agent/runner_capability_provider.dart` 존재 (`01_agent_port` 완료 증거) + - `apps/runner/lib/oto/commands/runner_command_capability_provider.dart` 존재 (`02+01_command_adapter` 완료 증거) +- [x] `OtoServerRegistrationClient`에서 command domain imports와 `_defaultCommandTypes()`를 제거한다. + - `import 'package:oto/oto/commands/command.dart'` 제거 + - `import 'package:oto/oto/commands/command_registry.dart'` 제거 + - `_defaultCommandTypes()` static 메서드 제거 + - `_DefaultRunnerCapabilityProvider` 클래스 제거 + - 생성자 fallback: `_StaticRunnerCapabilityProvider(commandTypes ?? const [])`로 교체 +- [x] default production 생성 경로에서 command adapter provider를 주입하되 기존 `commandTypes:` call site 호환을 유지한다. + - `agent_runner.dart`에 `runner_command_capability_provider.dart` import 추가 + - `DefaultAgentRunner` 기본 생성: `OtoServerRegistrationClient(capabilityProvider: const CommandCatalogRunnerCapabilityProvider())` + - 기존 `commandTypes:` 파라미터는 `_StaticRunnerCapabilityProvider`로 그대로 동작 +- [x] provider default path request build test와 HTTP payload test를 추가/갱신한다. + - `'builds registration request using CommandCatalogRunnerCapabilityProvider'` 추가 + - `'posts registration payload using CommandCatalogRunnerCapabilityProvider'` 추가 +- [x] `rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent` 결과가 의도된 예외 없이 비어 있는지 확인한다. + - `registration_client.dart`에 command import 없음 ✓ + - `agent_runner.dart` 1건: PLAN에서 명시한 wiring 지점의 의도된 결과 +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. + +## 코드리뷰 전용 체크리스트 + +- [x] PASS/WARN/FAIL 판정을 append한다. +- [x] active plan/review 파일을 `.log`로 아카이브한다. +- [x] PASS이면 `complete.log` 작성 후 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `_DefaultRunnerCapabilityProvider` 제거 후 null 케이스 처리: PLAN은 `_DefaultRunnerCapabilityProvider` 제거만 명시했으나, 생성자에서 `commandTypes == null`인 경우 fallback이 필요했다. `_StaticRunnerCapabilityProvider(commandTypes ?? const [])` 단일 경로로 정리해 `_DefaultRunnerCapabilityProvider` 클래스 자체를 제거했다. production path는 `agent_runner.dart`가 항상 명시적 provider를 주입하므로 empty fallback은 직접 사용되지 않는다. + +## 주요 설계 결정 + +- `agent_runner.dart`가 command domain import를 갖는 것은 의도된 설계: `registration_client.dart`는 `RunnerCapabilityProvider` 인터페이스만 소비하고, command 도메인 구체 타입은 `agent_runner.dart` 한 곳에서만 wiring한다. `rg` 결과에 `agent_runner.dart` 1건이 남는 것은 PLAN REFACTOR-1의 "wiring 지점" 설계 그대로다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `registration_client.dart`가 command domain import를 더 이상 갖지 않는지 확인 +- 기존 explicit `commandTypes:` call site가 깨지지 않는지 확인 +- server connection smoke가 registration payload 의미를 유지하는지 확인 + +## 검증 결과 + +### REFACTOR-1 중간 검증 + +```text +$ rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent +apps/runner/lib/oto/agent/agent_runner.dart:import 'package:oto/oto/commands/runner_command_capability_provider.dart'; + +$ cd apps/runner && dart test test/oto_agent_registration_test.dart +00:01 +38: All tests passed! +``` + +### 최종 검증 + +```text +$ cd apps/runner && dart analyze +Analyzing runner... +No issues found! + +$ cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_command_catalog_test.dart +00:01 +59: All tests passed! + +$ rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent +apps/runner/lib/oto/agent/agent_runner.dart:import 'package:oto/oto/commands/runner_command_capability_provider.dart'; +``` + +- `registration_client.dart`에 command import 없음 ✓ +- `agent_runner.dart` 1건은 PLAN 명시 wiring 지점 (의도된 예외) + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 리뷰어 검증: + - `cd apps/runner && dart analyze` - PASS; `No issues found!` + - `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_command_catalog_test.dart test/oto_server_connection_smoke_test.dart` - PASS; `+68: All tests passed!` + - `cd apps/runner && dart format --output=none --set-exit-if-changed lib/oto/agent/registration_client.dart lib/oto/agent/agent_runner.dart lib/oto/commands/runner_command_capability_provider.dart test/oto_agent_registration_test.dart test/oto_command_catalog_test.dart` - PASS; `Formatted 5 files (0 changed)` + - `rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent` - PASS; `agent_runner.dart`의 `runner_command_capability_provider.dart` import 1건은 PLAN REFACTOR-1에서 요구한 production wiring 지점이며, `registration_client.dart`에는 command domain import가 남아 있지 않다. +- 리뷰 중 정리: + - `dart format`으로 `registration_client.dart`, `agent_runner.dart`, `oto_agent_registration_test.dart`의 포맷 drift를 정리했다. +- 다음 단계: PASS이므로 active plan/review를 `.log`로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/complete.log b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/complete.log new file mode 100644 index 0000000..37b9700 --- /dev/null +++ b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/complete.log @@ -0,0 +1,45 @@ +# Complete - m-agent-registration-capability-boundary/03+01,02_registration_wiring + +## 완료 일시 + +2026-06-11T00:03:50Z + +## 요약 + +Agent registration production wiring 작업을 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | registration client가 command domain imports를 제거하고 provider port만 소비하며, DefaultAgentRunner가 command catalog adapter를 production default로 주입하는 경로가 계획 범위와 검증 기준을 충족했다. | + +## 구현/정리 내용 + +- `OtoServerRegistrationClient`에서 command domain imports와 `_defaultCommandTypes()` fallback을 제거하고 `RunnerCapabilityProvider` 기반 request build 경로로 정리했다. +- `DefaultAgentRunner` 기본 생성 경로에서 `CommandCatalogRunnerCapabilityProvider`를 주입해 production registration payload가 command catalog capability를 포함하도록 연결했다. +- provider default path request build test와 HTTP payload test를 추가했다. +- 리뷰 중 `dart format`으로 변경 Dart 파일의 포맷 drift를 정리했다. + +## 최종 검증 + +- `cd apps/runner && dart analyze` - PASS; `No issues found!` +- `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_command_catalog_test.dart test/oto_server_connection_smoke_test.dart` - PASS; `+68: All tests passed!` +- `cd apps/runner && dart format --output=none --set-exit-if-changed lib/oto/agent/registration_client.dart lib/oto/agent/agent_runner.dart lib/oto/commands/runner_command_capability_provider.dart test/oto_agent_registration_test.dart test/oto_command_catalog_test.dart` - PASS; `Formatted 5 files (0 changed)` +- `rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent` - PASS; output is the planned production composition import in `apps/runner/lib/oto/agent/agent_runner.dart`; `apps/runner/lib/oto/agent/registration_client.dart` has no command domain import. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/message-based-build-agent/milestones/agent-registration-capability-boundary.md` +- Completed task ids: + - `registration`: PASS; evidence=`plan_local_G06_0.log`, `code_review_local_G06_0.log`; verification=`cd apps/runner && dart analyze`, `cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_command_catalog_test.dart test/oto_server_connection_smoke_test.dart` + - `rule-evidence`: PASS; evidence=`plan_local_G06_0.log`, `code_review_local_G06_0.log`; verification=`rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-agent-registration-capability-boundary/03+01,02_registration_wiring/PLAN-local-G06.md b/agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/plan_local_G06_0.log similarity index 100% rename from agent-task/m-agent-registration-capability-boundary/03+01,02_registration_wiring/PLAN-local-G06.md rename to agent-task/archive/2026/06/m-agent-registration-capability-boundary/03+01,02_registration_wiring/plan_local_G06_0.log diff --git a/agent-task/m-agent-registration-capability-boundary/02+01_command_adapter/CODE_REVIEW-local-G05.md b/agent-task/m-agent-registration-capability-boundary/02+01_command_adapter/CODE_REVIEW-local-G05.md deleted file mode 100644 index c3768f3..0000000 --- a/agent-task/m-agent-registration-capability-boundary/02+01_command_adapter/CODE_REVIEW-local-G05.md +++ /dev/null @@ -1,78 +0,0 @@ - - -# Code Review Reference - REFACTOR - -## 개요 - -date=2026-06-10 -task=m-agent-registration-capability-boundary/02+01_command_adapter, plan=0, tag=REFACTOR - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/message-based-build-agent/milestones/agent-registration-capability-boundary.md` -- Task ids: - - `command-adapter`: command catalog 조회와 `registerAllCommands()` 호출을 command domain adapter 뒤로 이동한다. -- Completion mode: check-on-pass - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [REFACTOR-1] Command Adapter | [ ] | - -## 구현 체크리스트 - -- [ ] 선행 `agent-task/m-agent-registration-capability-boundary/01_agent_port/**/complete.log` 존재를 확인한다. -- [ ] command domain에 agent port 구현 adapter를 추가한다. -- [ ] adapter가 `registerAllCommands()` 호출 후 `Command.catalogRows`에서 type만 안정적으로 산출하게 한다. -- [ ] command catalog test에 adapter command type parity test를 추가한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] PASS/WARN/FAIL 판정을 append한다. -- [ ] active plan/review 파일을 `.log`로 아카이브한다. -- [ ] PASS이면 `complete.log` 작성 후 archive로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- adapter가 command 도메인에 있고 agent implementation 파일에 command import를 추가하지 않았는지 확인 -- `registerAllCommands()` 호출이 adapter 뒤로 이동했는지 확인 -- test가 command catalog와 adapter parity를 검증하는지 확인 - -## 검증 결과 - -### REFACTOR-1 중간 검증 - -```text -$ cd apps/runner && dart test test/oto_command_catalog_test.dart -(output) -``` - -### 최종 검증 - -```text -$ cd apps/runner && dart analyze -(output) - -$ cd apps/runner && dart test test/oto_command_catalog_test.dart test/oto_command_runtime_test.dart -(output) -``` diff --git a/agent-task/m-agent-registration-capability-boundary/03+01,02_registration_wiring/CODE_REVIEW-local-G06.md b/agent-task/m-agent-registration-capability-boundary/03+01,02_registration_wiring/CODE_REVIEW-local-G06.md deleted file mode 100644 index a1fdf0c..0000000 --- a/agent-task/m-agent-registration-capability-boundary/03+01,02_registration_wiring/CODE_REVIEW-local-G06.md +++ /dev/null @@ -1,86 +0,0 @@ - - -# Code Review Reference - REFACTOR - -## 개요 - -date=2026-06-10 -task=m-agent-registration-capability-boundary/03+01,02_registration_wiring, plan=0, tag=REFACTOR - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/message-based-build-agent/milestones/agent-registration-capability-boundary.md` -- Task ids: - - `registration`: 기존 registration payload 의미를 유지하면서 agent registration client가 adapter 계약만 소비하도록 바꾼다. - - `rule-evidence`: 변경 후 agent domain rule 위반 import가 남아 있지 않은지 확인한다. -- Completion mode: check-on-pass - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [REFACTOR-1] Registration Wiring | [ ] | - -## 구현 체크리스트 - -- [ ] 선행 `01_agent_port`, `02+01_command_adapter` complete evidence를 확인한다. -- [ ] `OtoServerRegistrationClient`에서 command domain imports와 `_defaultCommandTypes()`를 제거한다. -- [ ] default production 생성 경로에서 command adapter provider를 주입하되 기존 `commandTypes:` call site 호환을 유지한다. -- [ ] provider default path request build test와 HTTP payload test를 추가/갱신한다. -- [ ] `rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent` 결과가 의도된 예외 없이 비어 있는지 확인한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -- [ ] PASS/WARN/FAIL 판정을 append한다. -- [ ] active plan/review 파일을 `.log`로 아카이브한다. -- [ ] PASS이면 `complete.log` 작성 후 archive로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- `registration_client.dart`가 command domain import를 더 이상 갖지 않는지 확인 -- 기존 explicit `commandTypes:` call site가 깨지지 않는지 확인 -- server connection smoke가 registration payload 의미를 유지하는지 확인 - -## 검증 결과 - -### REFACTOR-1 중간 검증 - -```text -$ rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent -(output) - -$ cd apps/runner && dart test test/oto_agent_registration_test.dart -(output) -``` - -### 최종 검증 - -```text -$ cd apps/runner && dart analyze -(output) - -$ cd apps/runner && dart test test/oto_agent_registration_test.dart test/oto_command_catalog_test.dart test/oto_server_connection_smoke_test.dart -(output) - -$ rg --sort path "package:oto/oto/commands/" apps/runner/lib/oto/agent -(output) -``` diff --git a/apps/runner/lib/oto/agent/agent_runner.dart b/apps/runner/lib/oto/agent/agent_runner.dart index 517a4b1..d5cd59c 100644 --- a/apps/runner/lib/oto/agent/agent_runner.dart +++ b/apps/runner/lib/oto/agent/agent_runner.dart @@ -4,6 +4,7 @@ import 'package:oto/cli/cli.dart'; import 'package:oto/oto/agent/agent_config.dart'; import 'package:oto/oto/agent/registration_client.dart'; import 'package:oto/oto/agent/remote_run_executor.dart'; +import 'package:oto/oto/commands/runner_command_capability_provider.dart'; abstract class AgentRunner { Future run(AgentConfig config); @@ -29,7 +30,11 @@ class DefaultAgentRunner implements AgentRunner { void Function(String)? onLog, Future Function(AgentConfig, AgentSession)? runJobs, Future? shutdownFuture, - }) : _client = client ?? OtoServerRegistrationClient(), + }) : _client = + client ?? + OtoServerRegistrationClient( + capabilityProvider: const CommandCatalogRunnerCapabilityProvider(), + ), _onLog = onLog, _runJobs = runJobs, _shutdownFuture = shutdownFuture; @@ -100,11 +105,14 @@ class DefaultAgentRunner implements AgentRunner { var shutdownRequested = false; // Mark shutdown as soon as the future completes so the while-check exits. - shutdownFuture.then((_) { - shutdownRequested = true; - }, onError: (_) { - shutdownRequested = true; - }); + shutdownFuture.then( + (_) { + shutdownRequested = true; + }, + onError: (_) { + shutdownRequested = true; + }, + ); while (!shutdownRequested) { final claim = await session.jobs.claimNextJob(); diff --git a/apps/runner/lib/oto/agent/registration_client.dart b/apps/runner/lib/oto/agent/registration_client.dart index a91a759..4da1fae 100644 --- a/apps/runner/lib/oto/agent/registration_client.dart +++ b/apps/runner/lib/oto/agent/registration_client.dart @@ -7,8 +7,6 @@ import 'package:oto/oto/agent/agent_config.dart'; import 'package:oto/oto/agent/oto_server_job_client.dart'; import 'package:oto/oto/agent/oto/runner.pb.dart' as oto; import 'package:oto/oto/agent/runner_capability_provider.dart'; -import 'package:oto/oto/commands/command.dart'; -import 'package:oto/oto/commands/command_registry.dart'; const otoRunnerProtocolVersion = 'oto.runner.v1'; const otoRunnerCapabilityName = 'oto-runner'; @@ -126,9 +124,7 @@ class OtoServerRegistrationClient extends RegistrationClient { }) : _client = client, _capabilityProvider = capabilityProvider ?? - (commandTypes == null - ? const _DefaultRunnerCapabilityProvider() - : _StaticRunnerCapabilityProvider(commandTypes)), + _StaticRunnerCapabilityProvider(commandTypes ?? const []), _heartbeatInterval = heartbeatInterval; @override @@ -247,24 +243,6 @@ class OtoServerRegistrationClient extends RegistrationClient { 'command_types': request.commandCatalog.commandTypes.toList(), }, }; - - static List _defaultCommandTypes() { - registerAllCommands(); - return Command.catalogRows.map((row) => row['type']!).toList(); - } -} - -class _DefaultRunnerCapabilityProvider implements RunnerCapabilityProvider { - const _DefaultRunnerCapabilityProvider(); - - @override - RunnerCapabilitySnapshot snapshot() { - return RunnerCapabilitySnapshot( - name: otoRunnerCapabilityName, - version: otoRunnerCapabilityVersion, - commandTypes: OtoServerRegistrationClient._defaultCommandTypes(), - ); - } } class _StaticRunnerCapabilityProvider implements RunnerCapabilityProvider { diff --git a/apps/runner/lib/oto/commands/runner_command_capability_provider.dart b/apps/runner/lib/oto/commands/runner_command_capability_provider.dart new file mode 100644 index 0000000..2378feb --- /dev/null +++ b/apps/runner/lib/oto/commands/runner_command_capability_provider.dart @@ -0,0 +1,24 @@ +import 'package:oto/oto/agent/runner_capability_provider.dart'; +import 'package:oto/oto/commands/command.dart'; +import 'package:oto/oto/commands/command_registry.dart'; + +class CommandCatalogRunnerCapabilityProvider + implements RunnerCapabilityProvider { + final String name; + final String version; + + const CommandCatalogRunnerCapabilityProvider({ + this.name = 'oto-runner', + this.version = '1.0.0', + }); + + @override + RunnerCapabilitySnapshot snapshot() { + registerAllCommands(); + return RunnerCapabilitySnapshot( + name: name, + version: version, + commandTypes: Command.catalogRows.map((row) => row['type']!), + ); + } +} diff --git a/apps/runner/test/oto_agent_registration_test.dart b/apps/runner/test/oto_agent_registration_test.dart index 9d09453..a058932 100644 --- a/apps/runner/test/oto_agent_registration_test.dart +++ b/apps/runner/test/oto_agent_registration_test.dart @@ -10,6 +10,7 @@ import 'package:oto/oto/agent/registration_client.dart'; import 'package:oto/oto/agent/oto_server_job_client.dart'; import 'package:oto/oto/agent/remote_run_executor.dart'; import 'package:oto/oto/agent/runner_capability_provider.dart'; +import 'package:oto/oto/commands/runner_command_capability_provider.dart'; import 'package:oto/oto/application.dart'; import 'package:oto/oto/core/build_result.dart'; import 'package:oto/oto/core/execution_context.dart'; @@ -142,6 +143,76 @@ runtime: expect(request.commandCatalog.commandTypes, ['Print', 'ShellFile']); }); + test( + 'builds registration request using CommandCatalogRunnerCapabilityProvider', + () { + final client = OtoServerRegistrationClient( + capabilityProvider: const CommandCatalogRunnerCapabilityProvider(), + ); + + final request = client.buildRegisterRunnerRequest(validConfig); + + expect(request.enrollmentToken, 'token-456'); + expect(request.runnerId, 'agent-123'); + expect(request.capability.name, otoRunnerCapabilityName); + expect(request.capability.version, otoRunnerCapabilityVersion); + expect(request.commandCatalog.commandTypes, isNotEmpty); + expect(request.commandCatalog.commandTypes, contains('Shell')); + }, + ); + + test( + 'posts registration payload using CommandCatalogRunnerCapabilityProvider', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final captured = Completer>(); + final serverSub = server.listen((request) async { + if (request.uri.path == '/api/v1/runners/register') { + final body = await utf8.decoder.bind(request).join(); + captured.complete(jsonDecode(body) as Map); + request.response + ..headers.contentType = ContentType.json + ..write(jsonEncode({'accepted': true, 'runner_id': 'agent-123'})); + } else { + request.response + ..headers.contentType = ContentType.json + ..write(jsonEncode({'success': true})); + } + await request.response.close(); + }); + + try { + final config = AgentConfig( + agent: validConfig.agent, + server: ServerConnectionConfig( + url: 'http://${server.address.host}:${server.port}', + ), + runtime: validConfig.runtime, + ); + final client = OtoServerRegistrationClient( + capabilityProvider: const CommandCatalogRunnerCapabilityProvider(), + ); + + final result = await client.register(config); + + expect(result.accepted, isTrue); + final payload = await captured.future; + expect(payload['capability'], containsPair('name', 'oto-runner')); + expect( + (payload['command_catalog']['command_types'] as List), + isNotEmpty, + ); + expect( + (payload['command_catalog']['command_types'] as List), + contains('Shell'), + ); + } finally { + await serverSub.cancel(); + await server.close(force: true); + } + }, + ); + test('posts registration request to OTO Server endpoint', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final captured = Completer>(); @@ -189,7 +260,10 @@ runtime: expect(payload['alias'], 'my-agent'); expect(payload['protocol_version'], otoRunnerProtocolVersion); expect(payload['capability'], containsPair('name', 'oto-runner')); - expect(payload['capability'], containsPair('version', otoRunnerCapabilityVersion)); + expect( + payload['capability'], + containsPair('version', otoRunnerCapabilityVersion), + ); expect( payload['command_catalog'], containsPair('command_types', ['Shell', 'Git']), @@ -200,44 +274,51 @@ runtime: } }); - test('registration client handles incompatible runner error response', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final serverSub = server.listen((request) async { - request.response - ..headers.contentType = ContentType.json - ..write( - jsonEncode({ - 'accepted': false, - 'reject_reason': 'unsupported protocol version: "oto.runner.v2"', - 'error': { - 'code': 'incompatible_runner', - 'message': 'unsupported protocol version: "oto.runner.v2"', - } - }), + test( + 'registration client handles incompatible runner error response', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final serverSub = server.listen((request) async { + request.response + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'accepted': false, + 'reject_reason': + 'unsupported protocol version: "oto.runner.v2"', + 'error': { + 'code': 'incompatible_runner', + 'message': 'unsupported protocol version: "oto.runner.v2"', + }, + }), + ); + await request.response.close(); + }); + + try { + final config = AgentConfig( + agent: validConfig.agent, + server: ServerConnectionConfig( + url: 'http://${server.address.host}:${server.port}', + ), + runtime: validConfig.runtime, + ); + final client = OtoServerRegistrationClient( + commandTypes: ['Shell', 'Git'], ); - await request.response.close(); - }); - try { - final config = AgentConfig( - agent: validConfig.agent, - server: ServerConnectionConfig( - url: 'http://${server.address.host}:${server.port}', - ), - runtime: validConfig.runtime, - ); - final client = OtoServerRegistrationClient( - commandTypes: ['Shell', 'Git'], - ); - - final result = await client.register(config); - expect(result.accepted, isFalse); - expect(result.rejectReason, 'unsupported protocol version: "oto.runner.v2"'); - } finally { - await serverSub.cancel(); - await server.close(force: true); - } - }); + final result = await client.register(config); + expect(result.accepted, isFalse); + expect( + result.rejectReason, + 'unsupported protocol version: "oto.runner.v2"', + ); + } finally { + await serverSub.cancel(); + await server.close(force: true); + } + }, + ); test('sends first heartbeat and disconnects on close', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); @@ -565,288 +646,313 @@ runtime: } }); - test('OTO runner protocol exposes run, cancel, status, self-update message fields', () { - final runRequest = oto.RunRequest() - ..runnerId = 'agent-123' - ..jobId = 'job-123' - ..executionId = 'exec-123' - ..pipelineYamlPath = '/path/to/pipeline.yaml' - ..pipelineYaml = 'commands:\n - type: Shell' - ..variables.addAll({'foo': 'bar'}) - ..commandTypes.addAll(['Shell', 'Git']); + test( + 'OTO runner protocol exposes run, cancel, status, self-update message fields', + () { + final runRequest = oto.RunRequest() + ..runnerId = 'agent-123' + ..jobId = 'job-123' + ..executionId = 'exec-123' + ..pipelineYamlPath = '/path/to/pipeline.yaml' + ..pipelineYaml = 'commands:\n - type: Shell' + ..variables.addAll({'foo': 'bar'}) + ..commandTypes.addAll(['Shell', 'Git']); - expect(runRequest.runnerId, 'agent-123'); - expect(runRequest.jobId, 'job-123'); - expect(runRequest.executionId, 'exec-123'); - expect(runRequest.pipelineYamlPath, '/path/to/pipeline.yaml'); - expect(runRequest.pipelineYaml, 'commands:\n - type: Shell'); - expect(runRequest.variables['foo'], 'bar'); - expect(runRequest.commandTypes, ['Shell', 'Git']); + expect(runRequest.runnerId, 'agent-123'); + expect(runRequest.jobId, 'job-123'); + expect(runRequest.executionId, 'exec-123'); + expect(runRequest.pipelineYamlPath, '/path/to/pipeline.yaml'); + expect(runRequest.pipelineYaml, 'commands:\n - type: Shell'); + expect(runRequest.variables['foo'], 'bar'); + expect(runRequest.commandTypes, ['Shell', 'Git']); - final claimResponse = oto.JobClaimResponse() - ..accepted = true - ..runRequest = runRequest; - expect(claimResponse.runRequest.jobId, 'job-123'); + final claimResponse = oto.JobClaimResponse() + ..accepted = true + ..runRequest = runRequest; + expect(claimResponse.runRequest.jobId, 'job-123'); - final cancelRequest = oto.CancelRunRequest() - ..runnerId = 'agent-123' - ..executionId = 'exec-123' - ..reason = 'user cancelled'; - expect(cancelRequest.reason, 'user cancelled'); + final cancelRequest = oto.CancelRunRequest() + ..runnerId = 'agent-123' + ..executionId = 'exec-123' + ..reason = 'user cancelled'; + expect(cancelRequest.reason, 'user cancelled'); - final cancelResponse = oto.CancelRunResponse() - ..success = false - ..error = (oto.ProtocolError() - ..code = 'CANCEL_FAILED' - ..message = 'execution not found'); - expect(cancelResponse.error.code, 'CANCEL_FAILED'); - expect(cancelResponse.error.message, 'execution not found'); + final cancelResponse = oto.CancelRunResponse() + ..success = false + ..error = (oto.ProtocolError() + ..code = 'CANCEL_FAILED' + ..message = 'execution not found'); + expect(cancelResponse.error.code, 'CANCEL_FAILED'); + expect(cancelResponse.error.message, 'execution not found'); - final statusRequest = oto.RunnerStatusRequest() - ..runnerId = 'agent-123'; - expect(statusRequest.runnerId, 'agent-123'); + final statusRequest = oto.RunnerStatusRequest()..runnerId = 'agent-123'; + expect(statusRequest.runnerId, 'agent-123'); - final statusResponse = oto.RunnerStatusResponse() - ..runnerId = 'agent-123' - ..status = 'RUNNING' - ..currentExecutionId = 'exec-123'; - expect(statusResponse.status, 'RUNNING'); + final statusResponse = oto.RunnerStatusResponse() + ..runnerId = 'agent-123' + ..status = 'RUNNING' + ..currentExecutionId = 'exec-123'; + expect(statusResponse.status, 'RUNNING'); - final selfUpdateRequest = oto.SelfUpdateRequest() - ..runnerId = 'agent-123' - ..version = 'v2.0.0' - ..downloadUrl = 'http://example.com/download'; - expect(selfUpdateRequest.version, 'v2.0.0'); + final selfUpdateRequest = oto.SelfUpdateRequest() + ..runnerId = 'agent-123' + ..version = 'v2.0.0' + ..downloadUrl = 'http://example.com/download'; + expect(selfUpdateRequest.version, 'v2.0.0'); - final selfUpdateResponse = oto.SelfUpdateResponse() - ..success = true; - expect(selfUpdateResponse.success, isTrue); - }); + final selfUpdateResponse = oto.SelfUpdateResponse()..success = true; + expect(selfUpdateResponse.success, isTrue); + }, + ); - test('registration client fallback error parsing from ProtocolError map object', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final serverSub = server.listen((request) async { - request.response - ..headers.contentType = ContentType.json - ..write( - jsonEncode({ - 'accepted': false, - 'error': { - 'code': 'REGISTRATION_FAILED', - 'message': 'Failed to register via protocol error', - } - }), + test( + 'registration client fallback error parsing from ProtocolError map object', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final serverSub = server.listen((request) async { + request.response + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'accepted': false, + 'error': { + 'code': 'REGISTRATION_FAILED', + 'message': 'Failed to register via protocol error', + }, + }), + ); + await request.response.close(); + }); + + try { + final config = AgentConfig( + agent: validConfig.agent, + server: ServerConnectionConfig( + url: 'http://${server.address.host}:${server.port}', + ), + runtime: validConfig.runtime, + ); + final client = OtoServerRegistrationClient( + commandTypes: ['Shell', 'Git'], ); - await request.response.close(); - }); - try { - final config = AgentConfig( - agent: validConfig.agent, - server: ServerConnectionConfig( - url: 'http://${server.address.host}:${server.port}', - ), - runtime: validConfig.runtime, - ); - final client = OtoServerRegistrationClient( - commandTypes: ['Shell', 'Git'], - ); - - final result = await client.register(config); - expect(result.accepted, isFalse); - expect(result.rejectReason, 'Failed to register via protocol error'); - } finally { - await serverSub.cancel(); - await server.close(force: true); - } - }); - - test('job client fallback error parsing from ProtocolError map object', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final serverSub = server.listen((request) async { - request.response.headers.contentType = ContentType.json; - if (request.uri.path.contains('/jobs/claim')) { - request.response.write( - jsonEncode({ - 'accepted': false, - 'error': { - 'code': 'CLAIM_FAILED', - 'message': 'Failed to claim job via protocol error', - } - }), - ); - } else if (request.uri.path.contains('/report')) { - request.response.write( - jsonEncode({ - 'accepted': false, - 'error': { - 'code': 'REPORT_FAILED', - 'message': 'Failed to report execution via protocol error', - } - }), - ); - } else if (request.uri.path.contains('/logs')) { - request.response.statusCode = HttpStatus.created; - request.response.write( - jsonEncode({ - 'accepted': false, - 'error': { - 'code': 'LOG_FAILED', - 'message': 'Failed to append log via protocol error', - } - }), - ); - } else if (request.uri.path.contains('/artifacts')) { - request.response.statusCode = HttpStatus.created; - request.response.write( - jsonEncode({ - 'accepted': false, - 'error': { - 'code': 'ARTIFACT_FAILED', - 'message': 'Failed to report artifact via protocol error', - } - }), - ); + final result = await client.register(config); + expect(result.accepted, isFalse); + expect(result.rejectReason, 'Failed to register via protocol error'); + } finally { + await serverSub.cancel(); + await server.close(force: true); } - await request.response.close(); - }); + }, + ); - final client = OtoServerJobClient( - serverUrl: 'http://${server.address.host}:${server.port}', - runnerId: 'agent-123', - ); + test( + 'job client fallback error parsing from ProtocolError map object', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final serverSub = server.listen((request) async { + request.response.headers.contentType = ContentType.json; + if (request.uri.path.contains('/jobs/claim')) { + request.response.write( + jsonEncode({ + 'accepted': false, + 'error': { + 'code': 'CLAIM_FAILED', + 'message': 'Failed to claim job via protocol error', + }, + }), + ); + } else if (request.uri.path.contains('/report')) { + request.response.write( + jsonEncode({ + 'accepted': false, + 'error': { + 'code': 'REPORT_FAILED', + 'message': 'Failed to report execution via protocol error', + }, + }), + ); + } else if (request.uri.path.contains('/logs')) { + request.response.statusCode = HttpStatus.created; + request.response.write( + jsonEncode({ + 'accepted': false, + 'error': { + 'code': 'LOG_FAILED', + 'message': 'Failed to append log via protocol error', + }, + }), + ); + } else if (request.uri.path.contains('/artifacts')) { + request.response.statusCode = HttpStatus.created; + request.response.write( + jsonEncode({ + 'accepted': false, + 'error': { + 'code': 'ARTIFACT_FAILED', + 'message': 'Failed to report artifact via protocol error', + }, + }), + ); + } + await request.response.close(); + }); - try { - final claim = await client.claimJob( - jobId: 'job-123', - executionId: 'exec-123', - ); - expect(claim.accepted, isFalse); - expect(claim.errorMessage, 'Failed to claim job via protocol error'); - - final report = await client.reportExecution( - jobId: 'job-123', - executionId: 'exec-123', - result: BuildResult.failure('fail', StackTrace.empty), - ); - expect(report.accepted, isFalse); - expect(report.errorMessage, 'Failed to report execution via protocol error'); - - await expectLater( - client.appendLog(executionId: 'exec-123', line: 'test log'), - throwsA(isA().having((e) => e.message, 'message', 'Failed to append log via protocol error')), + final client = OtoServerJobClient( + serverUrl: 'http://${server.address.host}:${server.port}', + runnerId: 'agent-123', ); - await expectLater( - client.reportArtifact(executionId: 'exec-123', name: 'art', path: 'path'), - throwsA(isA().having((e) => e.message, 'message', 'Failed to report artifact via protocol error')), - ); - } finally { - client.close(); - await serverSub.cancel(); - await server.close(force: true); - } - }); - - test('job client cancels, fetches status, and requests self-update', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final cancelReceived = Completer>(); - final statusReceived = Completer(); - final updateReceived = Completer>(); - - final serverSub = server.listen((request) async { - final bodyText = await utf8.decoder.bind(request).join(); - final body = bodyText.isEmpty - ? {} - : jsonDecode(bodyText) as Map; - request.response.headers.contentType = ContentType.json; - - if (request.method == 'POST' && - request.uri.path == - '/api/v1/runners/agent-123/executions/exec-123/cancel') { - cancelReceived.complete(body); - request.response.write( - jsonEncode({ - 'success': true, - }), + try { + final claim = await client.claimJob( + jobId: 'job-123', + executionId: 'exec-123', ); - } else if (request.method == 'GET' && - request.uri.path == '/api/v1/runners/agent-123/status') { - statusReceived.complete(); - request.response.write( - jsonEncode({ - 'accepted': true, - 'runner_id': 'agent-123', - 'status': 'online', - 'current_job_id': 'job-123', - 'current_execution_id': 'exec-123', - 'message': 'running smoothly', - }), + expect(claim.accepted, isFalse); + expect(claim.errorMessage, 'Failed to claim job via protocol error'); + + final report = await client.reportExecution( + jobId: 'job-123', + executionId: 'exec-123', + result: BuildResult.failure('fail', StackTrace.empty), ); - } else if (request.method == 'POST' && - request.uri.path == '/api/v1/runners/agent-123/self-update') { - updateReceived.complete(body); - request.response.write( - jsonEncode({ - 'success': true, - 'accepted': true, - 'deferred': false, - 'target_version': 'v2.0.0', - 'message': 'update scheduled', - }), + expect(report.accepted, isFalse); + expect( + report.errorMessage, + 'Failed to report execution via protocol error', ); - } else { - request.response.statusCode = HttpStatus.notFound; - request.response.write(jsonEncode({'error': 'not found'})); + + await expectLater( + client.appendLog(executionId: 'exec-123', line: 'test log'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Failed to append log via protocol error', + ), + ), + ); + + await expectLater( + client.reportArtifact( + executionId: 'exec-123', + name: 'art', + path: 'path', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Failed to report artifact via protocol error', + ), + ), + ); + } finally { + client.close(); + await serverSub.cancel(); + await server.close(force: true); } - await request.response.close(); - }); + }, + ); - final client = OtoServerJobClient( - serverUrl: 'http://${server.address.host}:${server.port}', - runnerId: 'agent-123', - ); + test( + 'job client cancels, fetches status, and requests self-update', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final cancelReceived = Completer>(); + final statusReceived = Completer(); + final updateReceived = Completer>(); - try { - final cancelRes = await client.cancelRun( - executionId: 'exec-123', - reason: 'user cancel request', + final serverSub = server.listen((request) async { + final bodyText = await utf8.decoder.bind(request).join(); + final body = bodyText.isEmpty + ? {} + : jsonDecode(bodyText) as Map; + request.response.headers.contentType = ContentType.json; + + if (request.method == 'POST' && + request.uri.path == + '/api/v1/runners/agent-123/executions/exec-123/cancel') { + cancelReceived.complete(body); + request.response.write(jsonEncode({'success': true})); + } else if (request.method == 'GET' && + request.uri.path == '/api/v1/runners/agent-123/status') { + statusReceived.complete(); + request.response.write( + jsonEncode({ + 'accepted': true, + 'runner_id': 'agent-123', + 'status': 'online', + 'current_job_id': 'job-123', + 'current_execution_id': 'exec-123', + 'message': 'running smoothly', + }), + ); + } else if (request.method == 'POST' && + request.uri.path == '/api/v1/runners/agent-123/self-update') { + updateReceived.complete(body); + request.response.write( + jsonEncode({ + 'success': true, + 'accepted': true, + 'deferred': false, + 'target_version': 'v2.0.0', + 'message': 'update scheduled', + }), + ); + } else { + request.response.statusCode = HttpStatus.notFound; + request.response.write(jsonEncode({'error': 'not found'})); + } + await request.response.close(); + }); + + final client = OtoServerJobClient( + serverUrl: 'http://${server.address.host}:${server.port}', + runnerId: 'agent-123', ); - expect(cancelRes.success, isTrue); - final statusRes = await client.fetchRunnerStatus(); - expect(statusRes.accepted, isTrue); - expect(statusRes.runnerId, 'agent-123'); - expect(statusRes.status, 'online'); - expect(statusRes.currentJobId, 'job-123'); - expect(statusRes.currentExecutionId, 'exec-123'); - expect(statusRes.message, 'running smoothly'); + try { + final cancelRes = await client.cancelRun( + executionId: 'exec-123', + reason: 'user cancel request', + ); + expect(cancelRes.success, isTrue); - final updateRes = await client.requestSelfUpdate( - version: 'v2.0.0', - downloadUrl: 'https://example.com/binary', - ); - expect(updateRes.success, isTrue); - expect(updateRes.accepted, isTrue); - expect(updateRes.deferred, isFalse); - expect(updateRes.targetVersion, 'v2.0.0'); - expect(updateRes.message, 'update scheduled'); + final statusRes = await client.fetchRunnerStatus(); + expect(statusRes.accepted, isTrue); + expect(statusRes.runnerId, 'agent-123'); + expect(statusRes.status, 'online'); + expect(statusRes.currentJobId, 'job-123'); + expect(statusRes.currentExecutionId, 'exec-123'); + expect(statusRes.message, 'running smoothly'); - final cancelPayload = await cancelReceived.future; - expect(cancelPayload['runner_id'], 'agent-123'); - expect(cancelPayload['execution_id'], 'exec-123'); - expect(cancelPayload['reason'], 'user cancel request'); + final updateRes = await client.requestSelfUpdate( + version: 'v2.0.0', + downloadUrl: 'https://example.com/binary', + ); + expect(updateRes.success, isTrue); + expect(updateRes.accepted, isTrue); + expect(updateRes.deferred, isFalse); + expect(updateRes.targetVersion, 'v2.0.0'); + expect(updateRes.message, 'update scheduled'); - final updatePayload = await updateReceived.future; - expect(updatePayload['runner_id'], 'agent-123'); - expect(updatePayload['version'], 'v2.0.0'); - expect(updatePayload['download_url'], 'https://example.com/binary'); - } finally { - client.close(); - await serverSub.cancel(); - await server.close(force: true); - } - }); + final cancelPayload = await cancelReceived.future; + expect(cancelPayload['runner_id'], 'agent-123'); + expect(cancelPayload['execution_id'], 'exec-123'); + expect(cancelPayload['reason'], 'user cancel request'); + + final updatePayload = await updateReceived.future; + expect(updatePayload['runner_id'], 'agent-123'); + expect(updatePayload['version'], 'v2.0.0'); + expect(updatePayload['download_url'], 'https://example.com/binary'); + } finally { + client.close(); + await serverSub.cancel(); + await server.close(force: true); + } + }, + ); }); group('AgentRunner', () { @@ -960,7 +1066,8 @@ runtime: test('rejects path traversal in pipelineYamlPath', () { final parser = RunRequestParser(workspaceRoot: '/var/workspace'); - final request = oto.RunRequest()..pipelineYamlPath = '../../../etc/passwd'; + final request = oto.RunRequest() + ..pipelineYamlPath = '../../../etc/passwd'; expect(() => parser.parse(request), throwsA(isA())); }); @@ -975,11 +1082,15 @@ runtime: expect(result.yamlContent, 'commands:\n - type: Print'); }); - test('rejects sibling-prefix path escape (e.g. /var/workspace2 vs /var/workspace)', () { - final parser = RunRequestParser(workspaceRoot: '/var/workspace'); - final request = oto.RunRequest()..pipelineYamlPath = '/var/workspace2/evil.yaml'; - expect(() => parser.parse(request), throwsA(isA())); - }); + test( + 'rejects sibling-prefix path escape (e.g. /var/workspace2 vs /var/workspace)', + () { + final parser = RunRequestParser(workspaceRoot: '/var/workspace'); + final request = oto.RunRequest() + ..pipelineYamlPath = '/var/workspace2/evil.yaml'; + expect(() => parser.parse(request), throwsA(isA())); + }, + ); }); group('RemoteRunExecutor.mergeVariablesIntoYaml', () { @@ -990,12 +1101,15 @@ runtime: String yaml, Map variables, ) { - final merged = - RemoteRunExecutor.mergeVariablesIntoYaml(yaml, variables); + final merged = RemoteRunExecutor.mergeVariablesIntoYaml(yaml, variables); final root = Application.getMapFromYamlA(merged); expect(root, isNotNull, reason: 'merged document must parse as a map'); final property = root!['property']; - expect(property, isA(), reason: 'merged document must have property map'); + expect( + property, + isA(), + reason: 'merged document must have property map', + ); return Map.from(property as Map); } @@ -1037,10 +1151,7 @@ commands: test('empty variables leave the YAML untouched', () { const yaml = 'property:\n workspace: .\ncommands:\n - type: Print\n'; - expect( - RemoteRunExecutor.mergeVariablesIntoYaml(yaml, const {}), - yaml, - ); + expect(RemoteRunExecutor.mergeVariablesIntoYaml(yaml, const {}), yaml); }); test('invalid non-map property is left untouched for build validation', () { @@ -1053,8 +1164,9 @@ property: commands: - type: Print '''; - final merged = - RemoteRunExecutor.mergeVariablesIntoYaml(yaml, {'FLAVOR': 'release'}); + final merged = RemoteRunExecutor.mergeVariablesIntoYaml(yaml, { + 'FLAVOR': 'release', + }); expect(merged, yaml); // Confirm the preserved property is still the invalid (non-map) form. final root = Application.getMapFromYamlA(merged); @@ -1194,84 +1306,93 @@ commands: }); group('DefaultAgentRunner shutdown-aware loop', () { - test('no-job polling exits promptly when shutdownFuture completes', () async { - // Set up a FakeOtoServerJobSession that always returns no-job. - final shutdown = Completer(); - final fakeRegistrationClient = _FakeJobRegistrationClient( - result: RegistrationResult.accepted('node-1', null, {}), - jobClient: _SingleResponseJobClient([]), - onShutdown: shutdown, - ); + test( + 'no-job polling exits promptly when shutdownFuture completes', + () async { + // Set up a FakeOtoServerJobSession that always returns no-job. + final shutdown = Completer(); + final fakeRegistrationClient = _FakeJobRegistrationClient( + result: RegistrationResult.accepted('node-1', null, {}), + jobClient: _SingleResponseJobClient([]), + onShutdown: shutdown, + ); - final runner = DefaultAgentRunner( - client: fakeRegistrationClient, - onLog: (_) {}, - shutdownFuture: shutdown.future, - ); + final runner = DefaultAgentRunner( + client: fakeRegistrationClient, + onLog: (_) {}, + shutdownFuture: shutdown.future, + ); - // Complete shutdown after a short delay. - Future.delayed(const Duration(milliseconds: 50), shutdown.complete); + // Complete shutdown after a short delay. + Future.delayed(const Duration(milliseconds: 50), shutdown.complete); - final start = DateTime.now(); - await runner.run(validConfig); - final elapsed = DateTime.now().difference(start); + final start = DateTime.now(); + await runner.run(validConfig); + final elapsed = DateTime.now().difference(start); - // Should exit well before the 3-second polling delay. - expect(elapsed.inMilliseconds, lessThan(1000)); - expect(fakeRegistrationClient.lastSession!.closed, isTrue); - }); + // Should exit well before the 3-second polling delay. + expect(elapsed.inMilliseconds, lessThan(1000)); + expect(fakeRegistrationClient.lastSession!.closed, isTrue); + }, + ); - test('claimNextJob is invoked by default loop and queued job is executed', () async { - final shutdown = Completer(); - var jobExecuted = false; - final fakeRegistrationClient = _FakeJobRegistrationClient( - result: RegistrationResult.accepted('node-1', null, {}), - jobClient: _SingleResponseJobClient([ - JobClaimResult( - accepted: true, - jobId: 'job-loop-1', - executionId: 'exec-loop-1', - state: 'running', - runRequest: (oto.RunRequest() - ..pipelineYaml = - 'property:\n workspace: .\ncommands:\n - command: Print\n id: hello\n param:\n message: loop-test\npipeline:\n id: p\n workflow:\n - exe: hello'), - ), - ]), - onShutdown: shutdown, - onJobExecuted: () { - jobExecuted = true; - shutdown.complete(); - }, - ); + test( + 'claimNextJob is invoked by default loop and queued job is executed', + () async { + final shutdown = Completer(); + var jobExecuted = false; + final fakeRegistrationClient = _FakeJobRegistrationClient( + result: RegistrationResult.accepted('node-1', null, {}), + jobClient: _SingleResponseJobClient([ + JobClaimResult( + accepted: true, + jobId: 'job-loop-1', + executionId: 'exec-loop-1', + state: 'running', + runRequest: (oto.RunRequest() + ..pipelineYaml = + 'property:\n workspace: .\ncommands:\n - command: Print\n id: hello\n param:\n message: loop-test\npipeline:\n id: p\n workflow:\n - exe: hello'), + ), + ]), + onShutdown: shutdown, + onJobExecuted: () { + jobExecuted = true; + shutdown.complete(); + }, + ); - final runner = DefaultAgentRunner( - client: fakeRegistrationClient, - onLog: (_) {}, - shutdownFuture: shutdown.future, - ); + final runner = DefaultAgentRunner( + client: fakeRegistrationClient, + onLog: (_) {}, + shutdownFuture: shutdown.future, + ); - await runner.run(validConfig); - expect(jobExecuted, isTrue); - }); + await runner.run(validConfig); + expect(jobExecuted, isTrue); + }, + ); }); - } - class _SingleResponseJobClient extends OtoServerJobClient { final List _responses; int _callCount = 0; void Function()? _onExecuted; _SingleResponseJobClient(this._responses) - : super(serverUrl: 'http://localhost', runnerId: 'fake'); + : super(serverUrl: 'http://localhost', runnerId: 'fake'); @override Future claimNextJob() async { if (_callCount < _responses.length) { return _responses[_callCount++]; } - return const JobClaimResult(accepted: false, jobId: '', executionId: '', state: ''); + return const JobClaimResult( + accepted: false, + jobId: '', + executionId: '', + state: '', + ); } @override @@ -1282,11 +1403,18 @@ class _SingleResponseJobClient extends OtoServerJobClient { }) async { _onExecuted?.call(); return ExecutionReportResult( - accepted: true, jobId: jobId, executionId: executionId, state: 'succeeded'); + accepted: true, + jobId: jobId, + executionId: executionId, + state: 'succeeded', + ); } @override - Future appendLog({required String executionId, required String line}) async {} + Future appendLog({ + required String executionId, + required String line, + }) async {} } class _FakeOtoServerJobSession implements OtoServerJobSession { diff --git a/apps/runner/test/oto_command_catalog_test.dart b/apps/runner/test/oto_command_catalog_test.dart index 7b00923..a510501 100644 --- a/apps/runner/test/oto_command_catalog_test.dart +++ b/apps/runner/test/oto_command_catalog_test.dart @@ -1,8 +1,11 @@ import 'dart:io'; +import 'package:oto/oto/agent/registration_client.dart' + show otoRunnerCapabilityName, otoRunnerCapabilityVersion; import 'package:oto/oto/commands/command.dart'; import 'package:oto/oto/commands/command_catalog.dart'; import 'package:oto/oto/commands/command_registry.dart'; +import 'package:oto/oto/commands/runner_command_capability_provider.dart'; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; @@ -13,24 +16,28 @@ void main() { test('all command types are registered', () { final registered = Command.registeredTypes.toSet(); - final missing = - CommandType.values.where((t) => !registered.contains(t)).toList(); + final missing = CommandType.values + .where((t) => !registered.contains(t)) + .toList(); expect( missing, isEmpty, - reason: 'CommandType values without a Command.register() call: $missing. ' + reason: + 'CommandType values without a Command.register() call: $missing. ' 'Add registration in the relevant register*() function and call it from registerAllCommands().', ); }); test('all registered commands expose specs', () { final specs = Command.specs; - final missing = - Command.registeredTypes.where((t) => !specs.containsKey(t)).toList(); + final missing = Command.registeredTypes + .where((t) => !specs.containsKey(t)) + .toList(); expect( missing, isEmpty, - reason: 'Registered commands without a CommandSpec: $missing. ' + reason: + 'Registered commands without a CommandSpec: $missing. ' 'Pass spec: CommandSpec(...) to Command.register().', ); }); @@ -47,7 +54,8 @@ void main() { expect( missing, isEmpty, - reason: 'CommandSpec.samplePath must point to an existing file. ' + reason: + 'CommandSpec.samplePath must point to an existing file. ' 'Missing: $missing', ); }); @@ -60,8 +68,9 @@ void main() { final file = File(sample); if (!file.existsSync()) continue; final commandName = entry.key.name; - final pattern = - RegExp(r'^\s*-\s*command:\s*' + RegExp.escape(commandName) + r'\s*$'); + final pattern = RegExp( + r'^\s*-\s*command:\s*' + RegExp.escape(commandName) + r'\s*$', + ); final hasCommand = file.readAsLinesSync().any((line) { final trimmed = line.trimLeft(); if (trimmed.startsWith('#')) return false; @@ -74,7 +83,8 @@ void main() { expect( mismatches, isEmpty, - reason: 'CommandSpec.samplePath must reference a sample that actually ' + reason: + 'CommandSpec.samplePath must reference a sample that actually ' 'includes a non-commented `- command: ` line. Mismatches: ' '$mismatches', ); @@ -88,8 +98,11 @@ void main() { bad.add('${entry.key}'); } } - expect(bad, isEmpty, - reason: 'CommandSpec.category and dataModel must be non-empty: $bad'); + expect( + bad, + isEmpty, + reason: 'CommandSpec.category and dataModel must be non-empty: $bad', + ); }); // REVIEW_SAMPLE-3: stale iOS field name regression tests @@ -117,9 +130,13 @@ void main() { break; } } - expect(hasStaleWorkspacePath, isFalse, - reason: '10_build_ios.yaml contains stale field name `workspacePath`. ' - 'Use `xcworkspaceFilePath` or `xcodeProjectFilePath` to match DataBuildiOS/DataArchiveiOS.'); + expect( + hasStaleWorkspacePath, + isFalse, + reason: + '10_build_ios.yaml contains stale field name `workspacePath`. ' + 'Use `xcworkspaceFilePath` or `xcodeProjectFilePath` to match DataBuildiOS/DataArchiveiOS.', + ); }); test('iOS sample must not contain stale field: exportOptionsPlist', () { @@ -138,14 +155,16 @@ void main() { break; } } - expect(hasStaleExportOptionsPlist, isFalse, - reason: - '10_build_ios.yaml contains stale field name `exportOptionsPlist`. ' - 'Use `exportOptionPlistPath` to match DataExportiOS.'); + expect( + hasStaleExportOptionsPlist, + isFalse, + reason: + '10_build_ios.yaml contains stale field name `exportOptionsPlist`. ' + 'Use `exportOptionPlistPath` to match DataExportiOS.', + ); }); - test('iOS TestFlight samples must not contain stale field: username/password', - () { + test('iOS TestFlight samples must not contain stale field: username/password', () { const samplePath = 'assets/yaml/sample/10_build_ios.yaml'; final file = File(samplePath); if (!file.existsSync()) { @@ -211,9 +230,7 @@ void main() { for (final field in expectedFields) { // Look for field as a YAML key: optional whitespace, then the field name // followed by colon, not inside a comment - final pattern = RegExp( - r'^[ \t]+' + RegExp.escape(field) + r'\s*:', - ); + final pattern = RegExp(r'^[ \t]+' + RegExp.escape(field) + r'\s*:'); final lines = content.split('\n'); bool found = false; for (final line in lines) { @@ -228,10 +245,13 @@ void main() { } } - expect(missingFields, isEmpty, - reason: - '10_build_ios.yaml should contain fields matching actual data models. ' - 'Missing fields: $missingFields'); + expect( + missingFields, + isEmpty, + reason: + '10_build_ios.yaml should contain fields matching actual data models. ' + 'Missing fields: $missingFields', + ); }); test('all registered commands have samplePath (with allowlist)', () { @@ -257,91 +277,102 @@ void main() { } } } - expect(missingSample, isEmpty, - reason: - 'Registered public commands without samplePath must be explicitly allowlisted. ' - 'Missing: $missingSample. ' - 'If a command was intentionally not allowlisted, add a sample file and ' - 'specify samplePath in the CommandSpec.'); + expect( + missingSample, + isEmpty, + reason: + 'Registered public commands without samplePath must be explicitly allowlisted. ' + 'Missing: $missingSample. ' + 'If a command was intentionally not allowlisted, add a sample file and ' + 'specify samplePath in the CommandSpec.', + ); }); test( - 'all non-allowlisted commands have sample files containing the registered command', - () { - final allowlistedWithoutSample = { - 'SimpleCommand', - 'CreateAppData', - 'BuildDartCompile', - 'SMBAuth', - }; + 'all non-allowlisted commands have sample files containing the registered command', + () { + final allowlistedWithoutSample = { + 'SimpleCommand', + 'CreateAppData', + 'BuildDartCompile', + 'SMBAuth', + }; - final missingContent = []; - for (final entry in Command.specs.entries) { - final sample = entry.value.samplePath; - if (sample == null) continue; - final typeName = entry.key.name; - if (allowlistedWithoutSample.contains(typeName)) continue; - final file = File(sample); - if (!file.existsSync()) continue; - final pattern = RegExp( - r'^\s*-\s*command:\s*' + RegExp.escape(entry.key.name) + r'\s*$'); - final hasCommand = file.readAsLinesSync().any((line) { - final trimmed = line.trimLeft(); - if (trimmed.startsWith('#')) return false; - return pattern.hasMatch(line); - }); - if (!hasCommand) { - missingContent.add('${entry.key} -> $sample'); + final missingContent = []; + for (final entry in Command.specs.entries) { + final sample = entry.value.samplePath; + if (sample == null) continue; + final typeName = entry.key.name; + if (allowlistedWithoutSample.contains(typeName)) continue; + final file = File(sample); + if (!file.existsSync()) continue; + final pattern = RegExp( + r'^\s*-\s*command:\s*' + RegExp.escape(entry.key.name) + r'\s*$', + ); + final hasCommand = file.readAsLinesSync().any((line) { + final trimmed = line.trimLeft(); + if (trimmed.startsWith('#')) return false; + return pattern.hasMatch(line); + }); + if (!hasCommand) { + missingContent.add('${entry.key} -> $sample'); + } } - } - expect(missingContent, isEmpty, + expect( + missingContent, + isEmpty, reason: 'All non-allowlisted samplePath files must contain at least one non-commented `- command: ` line. ' - 'Missing: $missingContent'); - }); + 'Missing: $missingContent', + ); + }, + ); test( - 'GitHub PR samples use write tags (<@>) not read tags () for result storage', - () { - const samplePath = 'assets/yaml/sample/09_network.yaml'; - final file = File(samplePath); - if (!file.existsSync()) { - fail('Sample file not found: $samplePath'); - } - final content = file.readAsStringSync(); - - // Locate GitHub PR command blocks and check their write fields - final lines = content.split('\n'); - bool inGithubCreate = false; - bool inGithubList = false; - for (final line in lines) { - // Track whether we're inside a GitHub PR command block - if (line.contains(r'- command: GitHubPullRequestCreate') || - line.contains('- command: GitHubPullRequestList')) { - inGithubCreate = line.contains('GitHubPullRequestCreate'); - inGithubList = !inGithubCreate; - } else if (line.contains('- command: ') && - !line.contains('GitHubPullRequest')) { - inGithubCreate = false; - inGithubList = false; + 'GitHub PR samples use write tags (<@>) not read tags () for result storage', + () { + const samplePath = 'assets/yaml/sample/09_network.yaml'; + final file = File(samplePath); + if (!file.existsSync()) { + fail('Sample file not found: $samplePath'); } + final content = file.readAsStringSync(); - // In PR create/list blocks, setURL/setNumber/setValue must use write tag <@>, not read tag - if (inGithubCreate || inGithubList) { - final trimmed = line.trimLeft(); - if (trimmed.startsWith('setURL:') || - trimmed.startsWith('setNumber:') || - trimmed.startsWith('setValue:')) { - // Read tag - if (RegExp(r']+>').hasMatch(trimmed)) { - fail('GitHub PR command write fields must use write tags ' + // Locate GitHub PR command blocks and check their write fields + final lines = content.split('\n'); + bool inGithubCreate = false; + bool inGithubList = false; + for (final line in lines) { + // Track whether we're inside a GitHub PR command block + if (line.contains(r'- command: GitHubPullRequestCreate') || + line.contains('- command: GitHubPullRequestList')) { + inGithubCreate = line.contains('GitHubPullRequestCreate'); + inGithubList = !inGithubCreate; + } else if (line.contains('- command: ') && + !line.contains('GitHubPullRequest')) { + inGithubCreate = false; + inGithubList = false; + } + + // In PR create/list blocks, setURL/setNumber/setValue must use write tag <@>, not read tag + if (inGithubCreate || inGithubList) { + final trimmed = line.trimLeft(); + if (trimmed.startsWith('setURL:') || + trimmed.startsWith('setNumber:') || + trimmed.startsWith('setValue:')) { + // Read tag + if (RegExp(r']+>').hasMatch(trimmed)) { + fail( + 'GitHub PR command write fields must use write tags ' '(<@property...>) not read tags (). ' - 'Found: $trimmed'); + 'Found: $trimmed', + ); + } } } } - } - }); + }, + ); test('all samplePath YAML files parse as maps with a commands list', () { final parseFailures = []; @@ -370,7 +401,8 @@ void main() { final cmd = commands[i]; if (cmd is! Map || !cmd.containsKey('command')) { parseFailures.add( - '${entry.key} -> $sample: commands[$i] missing command key'); + '${entry.key} -> $sample: commands[$i] missing command key', + ); break; } } @@ -378,9 +410,12 @@ void main() { parseFailures.add('${entry.key} -> $sample: YAML parse error: $e'); } } - expect(parseFailures, isEmpty, - reason: - 'Sample YAML files must parse as valid maps with commands lists. Failures: $parseFailures'); + expect( + parseFailures, + isEmpty, + reason: + 'Sample YAML files must parse as valid maps with commands lists. Failures: $parseFailures', + ); }); test('README examples must not contain stale field workspacePath', () { @@ -391,9 +426,13 @@ void main() { final content = file.readAsStringSync(); for (final line in content.split('\n')) { if (line.trimLeft().startsWith('#')) continue; - expect(RegExp(r'^[ \t]+workspacePath\s*:').hasMatch(line), isFalse, - reason: 'README.md contains stale field `workspacePath`. ' - 'Use `xcworkspaceFilePath` or `xcodeProjectFilePath` to match DataBuildiOS/DataArchiveiOS.'); + expect( + RegExp(r'^[ \t]+workspacePath\s*:').hasMatch(line), + isFalse, + reason: + 'README.md contains stale field `workspacePath`. ' + 'Use `xcworkspaceFilePath` or `xcodeProjectFilePath` to match DataBuildiOS/DataArchiveiOS.', + ); } }); @@ -410,15 +449,24 @@ void main() { (c) => (c as Map)['command'] == 'AwsCli', orElse: () => null, ); - expect(awsCliBlock, isNotNull, - reason: '19_awscli.yaml must contain an AwsCli command block'); + expect( + awsCliBlock, + isNotNull, + reason: '19_awscli.yaml must contain an AwsCli command block', + ); final param = awsCliBlock['param'] as Map; - expect(param.containsKey('sub-command'), isTrue, - reason: - 'AwsCli sample must use the key sub-command (not subCommand) to match DataAwsCli @JsonKey(name: "sub-command")'); - expect(param.containsKey('subCommand'), isFalse, - reason: - 'AwsCli sample must not use the key subCommand; use sub-command instead'); + expect( + param.containsKey('sub-command'), + isTrue, + reason: + 'AwsCli sample must use the key sub-command (not subCommand) to match DataAwsCli @JsonKey(name: "sub-command")', + ); + expect( + param.containsKey('subCommand'), + isFalse, + reason: + 'AwsCli sample must not use the key subCommand; use sub-command instead', + ); }); test('Jira sample includes both id and token fields', () { @@ -434,15 +482,23 @@ void main() { (c) => (c as Map)['command'] == 'Jira', orElse: () => null, ); - expect(jiraBlock, isNotNull, - reason: '21_jira.yaml must contain a Jira command block'); + expect( + jiraBlock, + isNotNull, + reason: '21_jira.yaml must contain a Jira command block', + ); final param = jiraBlock['param'] as Map; - expect(param.containsKey('id'), isTrue, - reason: - 'Jira sample must include the id field for Jira domain/account'); - expect(param.containsKey('token'), isTrue, - reason: - 'Jira sample must include the token field for Jira API authentication'); + expect( + param.containsKey('id'), + isTrue, + reason: 'Jira sample must include the id field for Jira domain/account', + ); + expect( + param.containsKey('token'), + isTrue, + reason: + 'Jira sample must include the token field for Jira API authentication', + ); }); test('Protobuf sample commands list contains valid string entries', () { @@ -458,18 +514,30 @@ void main() { (c) => (c as Map)['command'] == 'Protobuf', orElse: () => null, ); - expect(protoBlock, isNotNull, - reason: '18_protobuf.yaml must contain a Protobuf command block'); + expect( + protoBlock, + isNotNull, + reason: '18_protobuf.yaml must contain a Protobuf command block', + ); final param = protoBlock['param'] as Map; - expect(param.containsKey('commands'), isTrue, - reason: 'Protobuf sample must include a commands field'); + expect( + param.containsKey('commands'), + isTrue, + reason: 'Protobuf sample must include a commands field', + ); final cmdList = param['commands'] as List; - expect(cmdList.isNotEmpty, isTrue, - reason: 'Protobuf commands list must not be empty'); + expect( + cmdList.isNotEmpty, + isTrue, + reason: 'Protobuf commands list must not be empty', + ); for (var item in cmdList) { - expect(item is String, isTrue, - reason: - 'Protobuf commands list must contain string entries. Got: $item (${item.runtimeType})'); + expect( + item is String, + isTrue, + reason: + 'Protobuf commands list must contain string entries. Got: $item (${item.runtimeType})', + ); } }); @@ -482,12 +550,15 @@ void main() { final content = file.readAsStringSync(); final node = loadYaml(content) as Map; final commands = node['commands'] as List; - final buildFlutterBlocks = - commands.where((c) => (c as Map)['command'] == 'BuildFlutter').toList(); + final buildFlutterBlocks = commands + .where((c) => (c as Map)['command'] == 'BuildFlutter') + .toList(); - expect(buildFlutterBlocks.isNotEmpty, isTrue, - reason: - '11_build_flutter.yaml must contain BuildFlutter command blocks'); + expect( + buildFlutterBlocks.isNotEmpty, + isTrue, + reason: '11_build_flutter.yaml must contain BuildFlutter command blocks', + ); final allowedPlatforms = { 'ios', @@ -496,23 +567,35 @@ void main() { 'web', 'macos', 'windows', - 'linux' + 'linux', }; for (var block in buildFlutterBlocks) { final param = (block as Map)['param'] as Map; - expect(param.containsKey('targetPath'), isFalse, - reason: - 'BuildFlutter sample must not use targetPath (stale field). Use workspace instead.'); - expect(param.containsKey('workspace'), isTrue, - reason: 'BuildFlutter sample must define workspace.'); - expect(param.containsKey('platform'), isTrue, - reason: 'BuildFlutter sample must define platform.'); + expect( + param.containsKey('targetPath'), + isFalse, + reason: + 'BuildFlutter sample must not use targetPath (stale field). Use workspace instead.', + ); + expect( + param.containsKey('workspace'), + isTrue, + reason: 'BuildFlutter sample must define workspace.', + ); + expect( + param.containsKey('platform'), + isTrue, + reason: 'BuildFlutter sample must define platform.', + ); final platform = param['platform']?.toString().toLowerCase(); - expect(allowedPlatforms.contains(platform), isTrue, - reason: - 'BuildFlutter platform must be one of $allowedPlatforms. Got: $platform'); + expect( + allowedPlatforms.contains(platform), + isTrue, + reason: + 'BuildFlutter platform must be one of $allowedPlatforms. Got: $platform', + ); } }); @@ -521,14 +604,17 @@ void main() { expect(entries, isNotEmpty); final firstJson = entries.first.toJson(); - expect(firstJson.keys, containsAll([ - 'command', - 'category', - 'dataModel', - 'samplePath', - 'hasSample', - 'sampleExists', - ])); + expect( + firstJson.keys, + containsAll([ + 'command', + 'category', + 'dataModel', + 'samplePath', + 'hasSample', + 'sampleExists', + ]), + ); expect(firstJson['command'], isA()); expect(firstJson['category'], isA()); expect(firstJson['dataModel'], isA()); @@ -536,6 +622,17 @@ void main() { expect(firstJson['sampleExists'], isA()); }); + test('command capability provider mirrors command catalog types', () { + final snapshot = const CommandCatalogRunnerCapabilityProvider().snapshot(); + final catalogTypes = Command.catalogRows + .map((row) => row['type']!) + .toList(); + + expect(snapshot.name, otoRunnerCapabilityName); + expect(snapshot.version, otoRunnerCapabilityVersion); + expect(snapshot.commandTypes, catalogTypes); + }); + test('command catalog filters category and command case-insensitively', () { final catalog = const CommandCatalog(); final buildEntries = catalog.entries(category: 'BUILD');