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:
toki 2026-06-11 09:12:28 +09:00
parent 6b8f6a5c1d
commit 0941d572e5
13 changed files with 1097 additions and 712 deletions

View file

@ -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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -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)
```

View file

@ -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)
```

View file

@ -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();

View file

@ -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 {

View file

@ -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']!),
);
}
}

View file

@ -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 {

View file

@ -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');