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
This commit is contained in:
parent
6b8f6a5c1d
commit
0941d572e5
13 changed files with 1097 additions and 712 deletions
|
|
@ -0,0 +1,101 @@
|
|||
<!-- task=m-agent-registration-capability-boundary/02+01_command_adapter plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<!-- task=m-agent-registration-capability-boundary/03+01,02_registration_wiring plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<!-- task=m-agent-registration-capability-boundary/02+01_command_adapter plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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)
|
||||
```
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<!-- task=m-agent-registration-capability-boundary/03+01,02_registration_wiring plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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)
|
||||
```
|
||||
|
|
@ -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<void> run(AgentConfig config);
|
||||
|
|
@ -29,7 +30,11 @@ class DefaultAgentRunner implements AgentRunner {
|
|||
void Function(String)? onLog,
|
||||
Future<void> Function(AgentConfig, AgentSession)? runJobs,
|
||||
Future<void>? 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();
|
||||
|
|
|
|||
|
|
@ -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<String> _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 {
|
||||
|
|
|
|||
|
|
@ -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']!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Map<String, dynamic>>();
|
||||
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<String, dynamic>);
|
||||
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<dynamic>),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
(payload['command_catalog']['command_types'] as List<dynamic>),
|
||||
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<Map<String, dynamic>>();
|
||||
|
|
@ -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<HttpException>().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<HttpException>().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<Map<String, dynamic>>();
|
||||
final statusReceived = Completer<void>();
|
||||
final updateReceived = Completer<Map<String, dynamic>>();
|
||||
|
||||
final serverSub = server.listen((request) async {
|
||||
final bodyText = await utf8.decoder.bind(request).join();
|
||||
final body = bodyText.isEmpty
|
||||
? <String, dynamic>{}
|
||||
: jsonDecode(bodyText) as Map<String, dynamic>;
|
||||
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<HttpException>().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<HttpException>().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<Map<String, dynamic>>();
|
||||
final statusReceived = Completer<void>();
|
||||
final updateReceived = Completer<Map<String, dynamic>>();
|
||||
|
||||
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
|
||||
? <String, dynamic>{}
|
||||
: jsonDecode(bodyText) as Map<String, dynamic>;
|
||||
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<ArgumentError>()));
|
||||
});
|
||||
|
||||
|
|
@ -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<ArgumentError>()));
|
||||
});
|
||||
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<ArgumentError>()));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('RemoteRunExecutor.mergeVariablesIntoYaml', () {
|
||||
|
|
@ -990,12 +1101,15 @@ runtime:
|
|||
String yaml,
|
||||
Map<String, String> 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<Map>(), reason: 'merged document must have property map');
|
||||
expect(
|
||||
property,
|
||||
isA<Map>(),
|
||||
reason: 'merged document must have property map',
|
||||
);
|
||||
return Map<String, dynamic>.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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<JobClaimResult> _responses;
|
||||
int _callCount = 0;
|
||||
void Function()? _onExecuted;
|
||||
|
||||
_SingleResponseJobClient(this._responses)
|
||||
: super(serverUrl: 'http://localhost', runnerId: 'fake');
|
||||
: super(serverUrl: 'http://localhost', runnerId: 'fake');
|
||||
|
||||
@override
|
||||
Future<JobClaimResult> 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<void> appendLog({required String executionId, required String line}) async {}
|
||||
Future<void> appendLog({
|
||||
required String executionId,
|
||||
required String line,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
class _FakeOtoServerJobSession implements OtoServerJobSession {
|
||||
|
|
|
|||
|
|
@ -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: <Type>` 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 = <String>[];
|
||||
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 = <String>[];
|
||||
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: <Type>` 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 <!property... is wrong here — must be write tag <@property...>
|
||||
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 <!property... is wrong here — must be write tag <@property...>
|
||||
if (RegExp(r'<![^>]+>').hasMatch(trimmed)) {
|
||||
fail(
|
||||
'GitHub PR command write fields must use write tags '
|
||||
'(<@property...>) not read tags (<!property...>). '
|
||||
'Found: $trimmed');
|
||||
'Found: $trimmed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('all samplePath YAML files parse as maps with a commands list', () {
|
||||
final parseFailures = <String>[];
|
||||
|
|
@ -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<String>());
|
||||
expect(firstJson['category'], isA<String>());
|
||||
expect(firstJson['dataModel'], isA<String>());
|
||||
|
|
@ -536,6 +622,17 @@ void main() {
|
|||
expect(firstJson['sampleExists'], isA<bool>());
|
||||
});
|
||||
|
||||
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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue