archive: move 05+02,03,04_socket_smoke artifacts to archive and update smoke tests
This commit is contained in:
parent
79f85337e4
commit
1d9e89bd7d
5 changed files with 1709 additions and 1611 deletions
|
|
@ -53,42 +53,46 @@ task=m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke, plan=0,
|
||||||
|
|
||||||
| 항목 | 완료 여부 |
|
| 항목 | 완료 여부 |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| [API-1] smoke 문서와 Makefile/test grouping 갱신 | [ ] |
|
| [API-1] smoke 문서와 Makefile/test grouping 갱신 | [x] |
|
||||||
| [API-2] runner socket smoke 대표성 보강 | [ ] |
|
| [API-2] runner socket smoke 대표성 보강 | [x] |
|
||||||
| [API-3] full local smoke 기록 | [ ] |
|
| [API-3] full local smoke 기록 | [x] |
|
||||||
|
|
||||||
## 구현 체크리스트
|
## 구현 체크리스트
|
||||||
|
|
||||||
- [ ] 선행 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary` PASS complete evidence를 확인한다.
|
- [x] 선행 `02+01_dispatch_state`, `03+01,02_runner_actions`, `04+01_compat_boundary` PASS complete evidence를 확인한다.
|
||||||
- [ ] [API-1] `agent-test/local/agent-smoke.md`와 Makefile/test grouping을 socket-first 기준으로 갱신한다. 검증: `cd apps/runner && dart analyze`
|
- [x] [API-1] `agent-test/local/agent-smoke.md`와 Makefile/test grouping을 socket-first 기준으로 갱신한다. 검증: `cd apps/runner && dart analyze`
|
||||||
- [ ] [API-2] runner socket smoke가 lifecycle/dispatch/action/compat boundary evidence를 대표하도록 보강한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
|
- [x] [API-2] runner socket smoke가 lifecycle/dispatch/action/compat boundary evidence를 대표하도록 보강한다. 검증: `cd apps/runner && dart test test/oto_server_connection_smoke_test.dart`
|
||||||
- [ ] [API-3] full local smoke 명령을 실행하고 결과를 기록한다. 검증: Go full test와 runner required test list
|
- [x] [API-3] full local smoke 명령을 실행하고 결과를 기록한다. 검증: Go full test와 runner required test list
|
||||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
## 코드리뷰 전용 체크리스트
|
## 코드리뷰 전용 체크리스트
|
||||||
|
|
||||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||||
|
|
||||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/`를 `agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
- [x] PASS이면 active task 디렉터리 `agent-task/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/`를 `agent-task/archive/YYYY/MM/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-runner-proto-socket-transport-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up plan을 작성하고 `complete.log`를 작성하지 않는다.
|
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up plan을 작성하고 `complete.log`를 작성하지 않는다.
|
||||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||||
|
|
||||||
## 계획 대비 변경 사항
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
- `oto_agent_registration_test.dart` 수정 없음: 이미 `group('OtoServerRegistrationClient (Compatibility Fallback HTTP)', ...)` 구조로 compat-boundary 단계에서 정리되어 있었다.
|
||||||
|
- `SDD.md` 수정 없음: SDD Evidence Map은 이미 S06 evidence 경로를 올바르게 가리키고 있어 변경 불필요.
|
||||||
|
- Makefile 수정 없음: `RUNNER_REQUIRED_TESTS`에 `oto_server_connection_smoke_test.dart`가 이미 포함되어 있고 smoke 문서의 agent unit 명령과 일치함을 확인.
|
||||||
|
|
||||||
## 주요 설계 결정
|
## 주요 설계 결정
|
||||||
|
|
||||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
- `oto_server_connection_smoke_test.dart` `main()` 내부를 `group('socket-first transport smoke', ...)`과 `group('compatibility fallback HTTP', ...)`로 분리하고 socket 그룹을 앞에 배치했다. 기존 테스트 코드는 내용 변경 없이 indentation(2→4 spaces) 조정과 그룹 래핑만 수행했다.
|
||||||
|
- 헬퍼 함수 영역의 섹션 주석을 `// ─── Socket session lifecycle tests`에서 `// ─── Test helpers`로 변경해 실제 내용(helper 함수들)과 일치하도록 했다.
|
||||||
|
- `agent-test/local/agent-smoke.md`에 `services/core/internal/runnersocket/**` 읽기 조건 추가, socket-first transport smoke group 통과를 필수 검증/판정 기준에 명시했다.
|
||||||
|
|
||||||
## 사용자 리뷰 요청
|
## 사용자 리뷰 요청
|
||||||
|
|
||||||
|
|
@ -111,38 +115,52 @@ _기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직
|
||||||
|
|
||||||
## 검증 결과
|
## 검증 결과
|
||||||
|
|
||||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
|
||||||
|
|
||||||
### API-1 중간 검증
|
### API-1 중간 검증
|
||||||
```bash
|
```bash
|
||||||
$ cd apps/runner && dart analyze
|
$ cd apps/runner && dart analyze
|
||||||
(output)
|
Analyzing runner...
|
||||||
|
No issues found!
|
||||||
```
|
```
|
||||||
|
|
||||||
### API-2 중간 검증
|
### API-2 중간 검증
|
||||||
```bash
|
```bash
|
||||||
$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
|
$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart
|
||||||
(output)
|
00:03 +3: socket-first transport smoke OtoServerSocketRegistrationClient socket session close is idempotent after server disconnect
|
||||||
|
00:04 +4: compatibility fallback HTTP OTO Dart runner registers with Go OTO Server via compatibility fallback HTTP, goes online, and disconnects on close
|
||||||
|
...
|
||||||
|
00:10 +12: All tests passed!
|
||||||
```
|
```
|
||||||
|
|
||||||
### API-3 중간 검증
|
### API-3 중간 검증 (최종 검증 겸용)
|
||||||
```bash
|
```bash
|
||||||
$ cd services/core && go test -count=1 ./...
|
$ cd services/core && go test -count=1 ./...
|
||||||
(output)
|
ok github.com/toki/oto/services/core/internal/cicdstate 0.005s
|
||||||
|
ok github.com/toki/oto/services/core/internal/httpserver 0.078s
|
||||||
|
ok github.com/toki/oto/services/core/internal/runnerregistry 0.004s
|
||||||
|
ok github.com/toki/oto/services/core/internal/runnersocket 0.049s
|
||||||
|
|
||||||
$ cd apps/runner && dart analyze
|
$ cd apps/runner && dart analyze
|
||||||
(output)
|
Analyzing runner...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
|
$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
|
||||||
(output)
|
00:14 +96: All tests passed!
|
||||||
```
|
```
|
||||||
|
|
||||||
### 최종 검증
|
### 최종 검증
|
||||||
```bash
|
```bash
|
||||||
$ cd services/core && go test -count=1 ./...
|
$ cd services/core && go test -count=1 ./...
|
||||||
(output)
|
ok github.com/toki/oto/services/core/internal/cicdstate 0.005s
|
||||||
|
ok github.com/toki/oto/services/core/internal/httpserver 0.078s
|
||||||
|
ok github.com/toki/oto/services/core/internal/runnerregistry 0.004s
|
||||||
|
ok github.com/toki/oto/services/core/internal/runnersocket 0.049s
|
||||||
|
|
||||||
$ cd apps/runner && dart analyze
|
$ cd apps/runner && dart analyze
|
||||||
(output)
|
Analyzing runner...
|
||||||
|
No issues found!
|
||||||
|
|
||||||
$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
|
$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart
|
||||||
(output)
|
00:14 +96: All tests passed!
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -150,3 +168,24 @@ $ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_a
|
||||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||||
> If anything is blank, go back and fill it in before saving this file.
|
> If anything is blank, go back and fill it in before saving this file.
|
||||||
> Leave review-agent-only sections unchanged.
|
> Leave review-agent-only sections unchanged.
|
||||||
|
|
||||||
|
## 코드리뷰 결과
|
||||||
|
|
||||||
|
- 종합 판정: PASS
|
||||||
|
- 차원별 평가:
|
||||||
|
- correctness: Pass
|
||||||
|
- completeness: Pass
|
||||||
|
- test coverage: Pass
|
||||||
|
- API contract: Pass
|
||||||
|
- code quality: Pass
|
||||||
|
- plan deviation: Pass
|
||||||
|
- verification trust: Pass
|
||||||
|
- spec conformance: Pass
|
||||||
|
- 발견된 문제: 없음
|
||||||
|
- 검증 확인:
|
||||||
|
- `git diff --check` - PASS; whitespace error 없음.
|
||||||
|
- `cd apps/runner && dart analyze` - PASS; `No issues found!`.
|
||||||
|
- `cd services/core && go test -count=1 ./...` - PASS; core packages 통과.
|
||||||
|
- `cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` - PASS; `+96: All tests passed!`.
|
||||||
|
- `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart` - PASS; socket-first transport smoke 3개와 compatibility fallback HTTP group이 분리되어 `+12: All tests passed!`.
|
||||||
|
- 다음 단계: PASS 절차로 active plan/review를 log로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Complete - m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke
|
||||||
|
|
||||||
|
## 완료 일시
|
||||||
|
|
||||||
|
2026-06-21
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
Runner proto-socket transport hardening의 socket smoke 정리 작업을 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
|
||||||
|
|
||||||
|
## 루프 이력
|
||||||
|
|
||||||
|
| Plan | Review | Verdict | 메모 |
|
||||||
|
|------|--------|---------|------|
|
||||||
|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | PASS | socket-first smoke group과 HTTP compatibility fallback group 분리, agent smoke 기준, full local smoke 검증이 충족됨 |
|
||||||
|
|
||||||
|
## 구현/정리 내용
|
||||||
|
|
||||||
|
- `agent-test/local/agent-smoke.md`에 runnersocket 변경 시 socket-first transport smoke group 통과 기준을 추가했다.
|
||||||
|
- `apps/runner/test/oto_server_connection_smoke_test.dart`에서 socket-first transport smoke 3개를 앞쪽 그룹으로 분리하고 HTTP compatibility fallback tests를 별도 그룹으로 유지했다.
|
||||||
|
- 선행 `dispatch-state`, `runner-actions`, `compat-boundary` split PASS complete evidence를 확인하고 S06 smoke evidence로 종합했다.
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
- `git diff --check` - PASS; whitespace error 없음.
|
||||||
|
- `cd apps/runner && dart analyze` - PASS; `No issues found!`.
|
||||||
|
- `cd services/core && go test -count=1 ./...` - PASS; `internal/cicdstate`, `internal/httpserver`, `internal/runnerregistry`, `internal/runnersocket` 통과.
|
||||||
|
- `cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart` - PASS; `+96: All tests passed!`.
|
||||||
|
- `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart` - PASS; socket-first transport smoke 3개와 compatibility fallback HTTP tests가 분리되어 `+12: All tests passed!`.
|
||||||
|
|
||||||
|
## Roadmap Completion
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/runner-proto-socket-transport-hardening.md`
|
||||||
|
- Completed task ids:
|
||||||
|
- `socket-smoke`: PASS; evidence=`agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/code_review_cloud_G07_0.log`; verification=`git diff --check`, `cd apps/runner && dart analyze`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`, `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart`
|
||||||
|
- Not completed task ids: 없음
|
||||||
|
|
||||||
|
## Spec Completion
|
||||||
|
|
||||||
|
- SDD: `agent-roadmap/sdd/control-plane-product-surface/runner-proto-socket-transport-hardening/SDD.md`
|
||||||
|
- Completed scenario ids:
|
||||||
|
- `S06`: PASS; task=`socket-smoke`; evidence=`agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-runner-proto-socket-transport-hardening/05+02,03,04_socket_smoke/code_review_cloud_G07_0.log`; verification=`git diff --check`, `cd apps/runner && dart analyze`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart`, `cd apps/runner && dart test -r expanded test/oto_server_connection_smoke_test.dart`
|
||||||
|
- Not completed scenario ids: 없음
|
||||||
|
|
||||||
|
## 잔여 Nit
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -10,7 +10,7 @@ last_rule_updated_at: 2026-06-12
|
||||||
|
|
||||||
## 읽기 조건
|
## 읽기 조건
|
||||||
|
|
||||||
- `apps/runner/lib/oto/agent/**`, `apps/runner/lib/cli/commands/command_agent.dart`, `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`, `services/core/cmd/oto-core/main.go`, `services/core/internal/httpserver/**`, 또는 `services/core/internal/runnerregistry/**` 변경 검증이 필요한 경우
|
- `apps/runner/lib/oto/agent/**`, `apps/runner/lib/cli/commands/command_agent.dart`, `apps/runner/assets/script/shell/oto_agent_bootstrap.sh`, `services/core/cmd/oto-core/main.go`, `services/core/internal/httpserver/**`, `services/core/internal/runnersocket/**`, 또는 `services/core/internal/runnerregistry/**` 변경 검증이 필요한 경우
|
||||||
|
|
||||||
## 적용 범위
|
## 적용 범위
|
||||||
|
|
||||||
|
|
@ -70,6 +70,7 @@ last_rule_updated_at: 2026-06-12
|
||||||
- runner agent 도메인 변경 후 agent 관련 `cd apps/runner && dart test ...` 명령을 실행한다.
|
- runner agent 도메인 변경 후 agent 관련 `cd apps/runner && dart test ...` 명령을 실행한다.
|
||||||
- services/core registration, bootstrap command, release URL validation 변경 후 `cd services/core && go test ./...`를 실행한다.
|
- services/core registration, bootstrap command, release URL validation 변경 후 `cd services/core && go test ./...`를 실행한다.
|
||||||
- OS별 bootstrap matrix 또는 release asset 이름을 바꾸면 `README.md`, `apps/runner/test/oto_agent_bootstrap_script_test.dart`, 이 문서의 asset 이름이 함께 갱신되었는지 확인한다.
|
- OS별 bootstrap matrix 또는 release asset 이름을 바꾸면 `README.md`, `apps/runner/test/oto_agent_bootstrap_script_test.dart`, 이 문서의 asset 이름이 함께 갱신되었는지 확인한다.
|
||||||
|
- `apps/runner/lib/oto/agent/registration_client.dart` 또는 `services/core/internal/runnersocket/**` 변경 후 `oto_server_connection_smoke_test.dart` socket-first transport smoke group(heartbeat/lifecycle, cancel dispatch, idempotent close)이 통과해야 한다.
|
||||||
|
|
||||||
## 보조 검증
|
## 보조 검증
|
||||||
|
|
||||||
|
|
@ -82,6 +83,7 @@ last_rule_updated_at: 2026-06-12
|
||||||
- `cd apps/runner && dart analyze`가 issue 없이 종료한다.
|
- `cd apps/runner && dart analyze`가 issue 없이 종료한다.
|
||||||
- 지정한 `cd apps/runner && dart test`가 모두 통과한다.
|
- 지정한 `cd apps/runner && dart test`가 모두 통과한다.
|
||||||
- 지정한 `cd services/core && go test ./...`가 모두 통과한다.
|
- 지정한 `cd services/core && go test ./...`가 모두 통과한다.
|
||||||
|
- `oto_server_connection_smoke_test.dart` socket-first transport smoke group의 heartbeat/lifecycle, cancel dispatch, idempotent close tests가 모두 통과한다. HTTP compatibility fallback HTTP group은 보조 evidence이며 기본 smoke 판정 기준이 아니다.
|
||||||
|
|
||||||
## 기준 출력 예시
|
## 기준 출력 예시
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,332 @@ const _runnerId = 'oto-smoke-runner';
|
||||||
const _runnerAlias = 'oto-smoke-alias';
|
const _runnerAlias = 'oto-smoke-alias';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
group('socket-first transport smoke', () {
|
||||||
|
test(
|
||||||
|
'OtoServerSocketRegistrationClient socket session sends heartbeat and disconnects on close',
|
||||||
|
() async {
|
||||||
|
final port = await _freePort();
|
||||||
|
final serverAddr = '$_host:$port';
|
||||||
|
|
||||||
|
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
|
||||||
|
|
||||||
|
final output = StringBuffer();
|
||||||
|
final stdoutSub = process.stdout
|
||||||
|
.transform(systemEncoding.decoder)
|
||||||
|
.listen(output.write, onError: output.write);
|
||||||
|
final stderrSub = process.stderr
|
||||||
|
.transform(systemEncoding.decoder)
|
||||||
|
.listen(output.write, onError: output.write);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _waitForPort(_host, port, process, output);
|
||||||
|
|
||||||
|
final agentConfig = AgentConfig(
|
||||||
|
agent: const AgentIdentityConfig(
|
||||||
|
id: _runnerId,
|
||||||
|
alias: _runnerAlias,
|
||||||
|
enrollmentToken: _token,
|
||||||
|
),
|
||||||
|
server: ServerConnectionConfig(
|
||||||
|
url: 'http://$serverAddr',
|
||||||
|
socketUrl: 'tcp://$socketAddr',
|
||||||
|
),
|
||||||
|
runtime: const AgentRuntimeConfig(
|
||||||
|
installDir: '/tmp/install',
|
||||||
|
workspaceRoot: '/tmp/workspace',
|
||||||
|
logDir: '/tmp/log',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final client = OtoServerSocketRegistrationClient(
|
||||||
|
commandTypes: ['Shell', 'Git'],
|
||||||
|
heartbeatInterval: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
|
||||||
|
final session = await client.openSession(agentConfig);
|
||||||
|
expect(session, isA<OtoServerPushJobSession>());
|
||||||
|
expect(session.result.accepted, isTrue);
|
||||||
|
expect(session.result.runnerId, _runnerId);
|
||||||
|
|
||||||
|
// Capture stream completion after close().
|
||||||
|
final pushSession = session as OtoServerPushJobSession;
|
||||||
|
final runRequestsDone = Completer<void>();
|
||||||
|
final cancelRequestsDone = Completer<void>();
|
||||||
|
final runSub = pushSession.runRequests.listen(
|
||||||
|
(_) {},
|
||||||
|
onDone: runRequestsDone.complete,
|
||||||
|
);
|
||||||
|
final cancelSub = pushSession.cancelRequests.listen(
|
||||||
|
(_) {},
|
||||||
|
onDone: cancelRequestsDone.complete,
|
||||||
|
);
|
||||||
|
|
||||||
|
final httpClient = http.Client();
|
||||||
|
try {
|
||||||
|
final isOnline = await _pollRunnerStatus(
|
||||||
|
httpClient,
|
||||||
|
serverAddr,
|
||||||
|
_runnerId,
|
||||||
|
'online',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isOnline,
|
||||||
|
isTrue,
|
||||||
|
reason: 'socket runner did not go online in registry',
|
||||||
|
);
|
||||||
|
|
||||||
|
// close() must cancel the heartbeat timer and close socket streams.
|
||||||
|
await session.close();
|
||||||
|
await runRequestsDone.future.timeout(
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
onTimeout: () =>
|
||||||
|
fail('runRequests stream did not close after session.close()'),
|
||||||
|
);
|
||||||
|
await cancelRequestsDone.future.timeout(
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
onTimeout: () => fail(
|
||||||
|
'cancelRequests stream did not close after session.close()',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await runSub.cancel();
|
||||||
|
await cancelSub.cancel();
|
||||||
|
|
||||||
|
final isDisconnected = await _pollRunnerStatus(
|
||||||
|
httpClient,
|
||||||
|
serverAddr,
|
||||||
|
_runnerId,
|
||||||
|
'disconnected',
|
||||||
|
timeout: const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isDisconnected,
|
||||||
|
isTrue,
|
||||||
|
reason: 'socket runner did not transition to disconnected',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
httpClient.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process.kill(ProcessSignal.sigterm);
|
||||||
|
await process.exitCode.timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () {
|
||||||
|
process.kill(ProcessSignal.sigkill);
|
||||||
|
return process.exitCode;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await stdoutSub.cancel();
|
||||||
|
await stderrSub.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'OtoServerSocketRegistrationClient receives socket cancel requests from HTTP cancel action',
|
||||||
|
() async {
|
||||||
|
final port = await _freePort();
|
||||||
|
final serverAddr = '$_host:$port';
|
||||||
|
const jobId = 'smoke-job-socket-cancel';
|
||||||
|
|
||||||
|
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
|
||||||
|
|
||||||
|
final output = StringBuffer();
|
||||||
|
final stdoutSub = process.stdout
|
||||||
|
.transform(systemEncoding.decoder)
|
||||||
|
.listen(output.write, onError: output.write);
|
||||||
|
final stderrSub = process.stderr
|
||||||
|
.transform(systemEncoding.decoder)
|
||||||
|
.listen(output.write, onError: output.write);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _waitForPort(_host, port, process, output);
|
||||||
|
|
||||||
|
final agentConfig = AgentConfig(
|
||||||
|
agent: const AgentIdentityConfig(
|
||||||
|
id: _runnerId,
|
||||||
|
alias: _runnerAlias,
|
||||||
|
enrollmentToken: _token,
|
||||||
|
),
|
||||||
|
server: ServerConnectionConfig(
|
||||||
|
url: 'http://$serverAddr',
|
||||||
|
socketUrl: 'tcp://$socketAddr',
|
||||||
|
),
|
||||||
|
runtime: const AgentRuntimeConfig(
|
||||||
|
installDir: '/tmp/install',
|
||||||
|
workspaceRoot: '/tmp/workspace',
|
||||||
|
logDir: '/tmp/log',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final client = OtoServerSocketRegistrationClient(
|
||||||
|
commandTypes: ['Shell'],
|
||||||
|
heartbeatInterval: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
final session = await client.openSession(agentConfig);
|
||||||
|
expect(session.result.accepted, isTrue);
|
||||||
|
final pushSession = session as OtoServerPushJobSession;
|
||||||
|
|
||||||
|
final runReceived = Completer<String>();
|
||||||
|
final runSub = pushSession.runRequests.listen((request) {
|
||||||
|
if (!runReceived.isCompleted) {
|
||||||
|
expect(request.runnerId, _runnerId);
|
||||||
|
expect(request.jobId, jobId);
|
||||||
|
expect(request.executionId, isNotEmpty);
|
||||||
|
runReceived.complete(request.executionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var expectedExecutionId = '';
|
||||||
|
final cancelReceived = Completer<String>();
|
||||||
|
final cancelSub = pushSession.cancelRequests.listen((request) {
|
||||||
|
if (!cancelReceived.isCompleted) {
|
||||||
|
expect(request.runnerId, _runnerId);
|
||||||
|
expect(request.executionId, expectedExecutionId);
|
||||||
|
expect(request.reason, 'socket cancel smoke');
|
||||||
|
cancelReceived.complete(request.executionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final httpClient = http.Client();
|
||||||
|
final jobs = OtoServerJobClient(
|
||||||
|
serverUrl: 'http://$serverAddr',
|
||||||
|
runnerId: _runnerId,
|
||||||
|
client: httpClient,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final createResp = await httpClient.post(
|
||||||
|
Uri.parse('http://$serverAddr/api/v1/jobs'),
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'id': jobId,
|
||||||
|
'name': 'socket cancel smoke',
|
||||||
|
'run_request': {
|
||||||
|
'pipeline_yaml': 'commands:\n - type: Shell',
|
||||||
|
'command_types': ['Shell'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createResp.statusCode, equals(201));
|
||||||
|
|
||||||
|
expectedExecutionId = await runReceived.future.timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
onTimeout: () => fail('socket RunRequest was not received'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final cancelRes = await jobs.cancelRun(
|
||||||
|
executionId: expectedExecutionId,
|
||||||
|
reason: 'socket cancel smoke',
|
||||||
|
);
|
||||||
|
expect(cancelRes.success, isTrue);
|
||||||
|
|
||||||
|
await cancelReceived.future.timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
onTimeout: () => fail('socket CancelRunRequest was not received'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final execResp = await httpClient.get(
|
||||||
|
Uri.parse(
|
||||||
|
'http://$serverAddr/api/v1/executions/$expectedExecutionId',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final execJson = jsonDecode(execResp.body) as Map<String, dynamic>;
|
||||||
|
expect(execJson['state'], equals('canceled'));
|
||||||
|
} finally {
|
||||||
|
await runSub.cancel();
|
||||||
|
await cancelSub.cancel();
|
||||||
|
httpClient.close();
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
process.kill(ProcessSignal.sigterm);
|
||||||
|
await process.exitCode.timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () {
|
||||||
|
process.kill(ProcessSignal.sigkill);
|
||||||
|
return process.exitCode;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await stdoutSub.cancel();
|
||||||
|
await stderrSub.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'OtoServerSocketRegistrationClient socket session close is idempotent after server disconnect',
|
||||||
|
() async {
|
||||||
|
final port = await _freePort();
|
||||||
|
final serverAddr = '$_host:$port';
|
||||||
|
|
||||||
|
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
|
||||||
|
|
||||||
|
final output = StringBuffer();
|
||||||
|
final stdoutSub = process.stdout
|
||||||
|
.transform(systemEncoding.decoder)
|
||||||
|
.listen(output.write, onError: output.write);
|
||||||
|
final stderrSub = process.stderr
|
||||||
|
.transform(systemEncoding.decoder)
|
||||||
|
.listen(output.write, onError: output.write);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _waitForPort(_host, port, process, output);
|
||||||
|
|
||||||
|
final agentConfig = AgentConfig(
|
||||||
|
agent: const AgentIdentityConfig(
|
||||||
|
id: _runnerId,
|
||||||
|
alias: _runnerAlias,
|
||||||
|
enrollmentToken: _token,
|
||||||
|
),
|
||||||
|
server: ServerConnectionConfig(
|
||||||
|
url: 'http://$serverAddr',
|
||||||
|
socketUrl: 'tcp://$socketAddr',
|
||||||
|
),
|
||||||
|
runtime: const AgentRuntimeConfig(
|
||||||
|
installDir: '/tmp/install',
|
||||||
|
workspaceRoot: '/tmp/workspace',
|
||||||
|
logDir: '/tmp/log',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final client = OtoServerSocketRegistrationClient(
|
||||||
|
commandTypes: ['Shell'],
|
||||||
|
// Short interval so in-flight heartbeats exercise the catch block.
|
||||||
|
heartbeatInterval: const Duration(milliseconds: 50),
|
||||||
|
);
|
||||||
|
|
||||||
|
final session = await client.openSession(agentConfig);
|
||||||
|
expect(session.result.accepted, isTrue);
|
||||||
|
|
||||||
|
// Graceful server shutdown: closes all proto-socket clients cleanly,
|
||||||
|
// sending EOF to the Dart side. In-flight heartbeat sendRequest calls
|
||||||
|
// receive StateError('connection closed'), which is swallowed by the
|
||||||
|
// _sendHeartbeat catch block.
|
||||||
|
process.kill(ProcessSignal.sigterm);
|
||||||
|
await process.exitCode.timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
// Allow the EOF to propagate and the transport to auto-close.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
// session.close() must complete without throwing even though the
|
||||||
|
// proto-socket transport is already closed by the EOF handler.
|
||||||
|
await expectLater(session.close(), completes);
|
||||||
|
} finally {
|
||||||
|
process.kill(ProcessSignal.sigkill);
|
||||||
|
await process.exitCode.timeout(
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
onTimeout: () => -1,
|
||||||
|
);
|
||||||
|
await stdoutSub.cancel();
|
||||||
|
await stderrSub.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
group('compatibility fallback HTTP', () {
|
||||||
test(
|
test(
|
||||||
'OTO Dart runner registers with Go OTO Server via compatibility fallback HTTP, goes online, and disconnects on close',
|
'OTO Dart runner registers with Go OTO Server via compatibility fallback HTTP, goes online, and disconnects on close',
|
||||||
() async {
|
() async {
|
||||||
|
|
@ -224,8 +550,8 @@ void main() {
|
||||||
await fakeBinDir.create(recursive: true);
|
await fakeBinDir.create(recursive: true);
|
||||||
final fakeCurl = File('${fakeBinDir.path}/curl');
|
final fakeCurl = File('${fakeBinDir.path}/curl');
|
||||||
await fakeCurl.writeAsString('''#!/bin/sh
|
await fakeCurl.writeAsString('''#!/bin/sh
|
||||||
out_file=""
|
out_file=""
|
||||||
while [ \$# -gt 0 ]; do
|
while [ \$# -gt 0 ]; do
|
||||||
if [ "\$1" = "-o" ]; then
|
if [ "\$1" = "-o" ]; then
|
||||||
out_file="\$2"
|
out_file="\$2"
|
||||||
shift 2
|
shift 2
|
||||||
|
|
@ -234,14 +560,14 @@ while [ \$# -gt 0 ]; do
|
||||||
else
|
else
|
||||||
shift 1
|
shift 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ -n "\$out_file" ]; then
|
if [ -n "\$out_file" ]; then
|
||||||
cp "${fakeTarGz.path}" "\$out_file"
|
cp "${fakeTarGz.path}" "\$out_file"
|
||||||
else
|
else
|
||||||
cat "${scriptFile.absolute.path}"
|
cat "${scriptFile.absolute.path}"
|
||||||
fi
|
fi
|
||||||
''');
|
''');
|
||||||
await Process.run('chmod', ['+x', fakeCurl.path]);
|
await Process.run('chmod', ['+x', fakeCurl.path]);
|
||||||
|
|
||||||
// Append custom paths and options to the command string
|
// Append custom paths and options to the command string
|
||||||
|
|
@ -573,18 +899,18 @@ fi
|
||||||
'name': 'remote run smoke',
|
'name': 'remote run smoke',
|
||||||
'run_request': {
|
'run_request': {
|
||||||
'pipeline_yaml': '''
|
'pipeline_yaml': '''
|
||||||
property:
|
property:
|
||||||
workspace: .
|
workspace: .
|
||||||
commands:
|
commands:
|
||||||
- command: Print
|
- command: Print
|
||||||
id: say-hello
|
id: say-hello
|
||||||
param:
|
param:
|
||||||
message: hello from smoke
|
message: hello from smoke
|
||||||
pipeline:
|
pipeline:
|
||||||
id: smoke-pipe
|
id: smoke-pipe
|
||||||
workflow:
|
workflow:
|
||||||
- exe: say-hello
|
- exe: say-hello
|
||||||
''',
|
''',
|
||||||
'command_types': ['Print'],
|
'command_types': ['Print'],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -725,19 +1051,19 @@ pipeline:
|
||||||
'name': 'variable merge smoke',
|
'name': 'variable merge smoke',
|
||||||
'run_request': {
|
'run_request': {
|
||||||
'pipeline_yaml': '''
|
'pipeline_yaml': '''
|
||||||
property:
|
property:
|
||||||
workspace: .
|
workspace: .
|
||||||
FLAVOR: original
|
FLAVOR: original
|
||||||
commands:
|
commands:
|
||||||
- command: Print
|
- command: Print
|
||||||
id: print-flavor
|
id: print-flavor
|
||||||
param:
|
param:
|
||||||
message: "<!property.FLAVOR>"
|
message: "<!property.FLAVOR>"
|
||||||
pipeline:
|
pipeline:
|
||||||
id: var-pipe
|
id: var-pipe
|
||||||
workflow:
|
workflow:
|
||||||
- exe: print-flavor
|
- exe: print-flavor
|
||||||
''',
|
''',
|
||||||
'variables': {'FLAVOR': 'release'},
|
'variables': {'FLAVOR': 'release'},
|
||||||
'command_types': ['Print'],
|
'command_types': ['Print'],
|
||||||
},
|
},
|
||||||
|
|
@ -881,18 +1207,18 @@ pipeline:
|
||||||
'name': 'invalid property smoke',
|
'name': 'invalid property smoke',
|
||||||
'run_request': {
|
'run_request': {
|
||||||
'pipeline_yaml': '''
|
'pipeline_yaml': '''
|
||||||
property:
|
property:
|
||||||
- bad
|
- bad
|
||||||
commands:
|
commands:
|
||||||
- command: Print
|
- command: Print
|
||||||
id: print-flavor
|
id: print-flavor
|
||||||
param:
|
param:
|
||||||
message: "<!property.FLAVOR>"
|
message: "<!property.FLAVOR>"
|
||||||
pipeline:
|
pipeline:
|
||||||
id: bad-pipe
|
id: bad-pipe
|
||||||
workflow:
|
workflow:
|
||||||
- exe: print-flavor
|
- exe: print-flavor
|
||||||
''',
|
''',
|
||||||
'variables': {'FLAVOR': 'release'},
|
'variables': {'FLAVOR': 'release'},
|
||||||
'command_types': ['Print'],
|
'command_types': ['Print'],
|
||||||
},
|
},
|
||||||
|
|
@ -1146,21 +1472,21 @@ pipeline:
|
||||||
'name': 'artifact event smoke',
|
'name': 'artifact event smoke',
|
||||||
'run_request': {
|
'run_request': {
|
||||||
'pipeline_yaml': '''
|
'pipeline_yaml': '''
|
||||||
property:
|
property:
|
||||||
workspace: .
|
workspace: .
|
||||||
artifacts:
|
artifacts:
|
||||||
- name: smoke-report
|
- name: smoke-report
|
||||||
path: dist/report.json
|
path: dist/report.json
|
||||||
commands:
|
commands:
|
||||||
- command: Print
|
- command: Print
|
||||||
id: say-hello
|
id: say-hello
|
||||||
param:
|
param:
|
||||||
message: hello from artifact smoke
|
message: hello from artifact smoke
|
||||||
pipeline:
|
pipeline:
|
||||||
id: artifact-pipe
|
id: artifact-pipe
|
||||||
workflow:
|
workflow:
|
||||||
- exe: say-hello
|
- exe: say-hello
|
||||||
''',
|
''',
|
||||||
'command_types': ['Print'],
|
'command_types': ['Print'],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -1353,330 +1679,10 @@ pipeline:
|
||||||
timeout: const Timeout(Duration(seconds: 30)),
|
timeout: const Timeout(Duration(seconds: 30)),
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
|
||||||
'OtoServerSocketRegistrationClient socket session sends heartbeat and disconnects on close',
|
|
||||||
() async {
|
|
||||||
final port = await _freePort();
|
|
||||||
final serverAddr = '$_host:$port';
|
|
||||||
|
|
||||||
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
|
|
||||||
|
|
||||||
final output = StringBuffer();
|
|
||||||
final stdoutSub = process.stdout
|
|
||||||
.transform(systemEncoding.decoder)
|
|
||||||
.listen(output.write, onError: output.write);
|
|
||||||
final stderrSub = process.stderr
|
|
||||||
.transform(systemEncoding.decoder)
|
|
||||||
.listen(output.write, onError: output.write);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _waitForPort(_host, port, process, output);
|
|
||||||
|
|
||||||
final agentConfig = AgentConfig(
|
|
||||||
agent: const AgentIdentityConfig(
|
|
||||||
id: _runnerId,
|
|
||||||
alias: _runnerAlias,
|
|
||||||
enrollmentToken: _token,
|
|
||||||
),
|
|
||||||
server: ServerConnectionConfig(
|
|
||||||
url: 'http://$serverAddr',
|
|
||||||
socketUrl: 'tcp://$socketAddr',
|
|
||||||
),
|
|
||||||
runtime: const AgentRuntimeConfig(
|
|
||||||
installDir: '/tmp/install',
|
|
||||||
workspaceRoot: '/tmp/workspace',
|
|
||||||
logDir: '/tmp/log',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = OtoServerSocketRegistrationClient(
|
|
||||||
commandTypes: ['Shell', 'Git'],
|
|
||||||
heartbeatInterval: const Duration(milliseconds: 200),
|
|
||||||
);
|
|
||||||
|
|
||||||
final session = await client.openSession(agentConfig);
|
|
||||||
expect(session, isA<OtoServerPushJobSession>());
|
|
||||||
expect(session.result.accepted, isTrue);
|
|
||||||
expect(session.result.runnerId, _runnerId);
|
|
||||||
|
|
||||||
// Capture stream completion after close().
|
|
||||||
final pushSession = session as OtoServerPushJobSession;
|
|
||||||
final runRequestsDone = Completer<void>();
|
|
||||||
final cancelRequestsDone = Completer<void>();
|
|
||||||
final runSub = pushSession.runRequests.listen(
|
|
||||||
(_) {},
|
|
||||||
onDone: runRequestsDone.complete,
|
|
||||||
);
|
|
||||||
final cancelSub = pushSession.cancelRequests.listen(
|
|
||||||
(_) {},
|
|
||||||
onDone: cancelRequestsDone.complete,
|
|
||||||
);
|
|
||||||
|
|
||||||
final httpClient = http.Client();
|
|
||||||
try {
|
|
||||||
final isOnline = await _pollRunnerStatus(
|
|
||||||
httpClient,
|
|
||||||
serverAddr,
|
|
||||||
_runnerId,
|
|
||||||
'online',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
isOnline,
|
|
||||||
isTrue,
|
|
||||||
reason: 'socket runner did not go online in registry',
|
|
||||||
);
|
|
||||||
|
|
||||||
// close() must cancel the heartbeat timer and close socket streams.
|
|
||||||
await session.close();
|
|
||||||
await runRequestsDone.future.timeout(
|
|
||||||
const Duration(seconds: 2),
|
|
||||||
onTimeout: () =>
|
|
||||||
fail('runRequests stream did not close after session.close()'),
|
|
||||||
);
|
|
||||||
await cancelRequestsDone.future.timeout(
|
|
||||||
const Duration(seconds: 2),
|
|
||||||
onTimeout: () => fail(
|
|
||||||
'cancelRequests stream did not close after session.close()',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await runSub.cancel();
|
|
||||||
await cancelSub.cancel();
|
|
||||||
|
|
||||||
final isDisconnected = await _pollRunnerStatus(
|
|
||||||
httpClient,
|
|
||||||
serverAddr,
|
|
||||||
_runnerId,
|
|
||||||
'disconnected',
|
|
||||||
timeout: const Duration(seconds: 5),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
isDisconnected,
|
|
||||||
isTrue,
|
|
||||||
reason: 'socket runner did not transition to disconnected',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
httpClient.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
process.kill(ProcessSignal.sigterm);
|
|
||||||
await process.exitCode.timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
onTimeout: () {
|
|
||||||
process.kill(ProcessSignal.sigkill);
|
|
||||||
return process.exitCode;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await stdoutSub.cancel();
|
|
||||||
await stderrSub.cancel();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timeout: const Timeout(Duration(seconds: 30)),
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'OtoServerSocketRegistrationClient receives socket cancel requests from HTTP cancel action',
|
|
||||||
() async {
|
|
||||||
final port = await _freePort();
|
|
||||||
final serverAddr = '$_host:$port';
|
|
||||||
const jobId = 'smoke-job-socket-cancel';
|
|
||||||
|
|
||||||
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
|
|
||||||
|
|
||||||
final output = StringBuffer();
|
|
||||||
final stdoutSub = process.stdout
|
|
||||||
.transform(systemEncoding.decoder)
|
|
||||||
.listen(output.write, onError: output.write);
|
|
||||||
final stderrSub = process.stderr
|
|
||||||
.transform(systemEncoding.decoder)
|
|
||||||
.listen(output.write, onError: output.write);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _waitForPort(_host, port, process, output);
|
|
||||||
|
|
||||||
final agentConfig = AgentConfig(
|
|
||||||
agent: const AgentIdentityConfig(
|
|
||||||
id: _runnerId,
|
|
||||||
alias: _runnerAlias,
|
|
||||||
enrollmentToken: _token,
|
|
||||||
),
|
|
||||||
server: ServerConnectionConfig(
|
|
||||||
url: 'http://$serverAddr',
|
|
||||||
socketUrl: 'tcp://$socketAddr',
|
|
||||||
),
|
|
||||||
runtime: const AgentRuntimeConfig(
|
|
||||||
installDir: '/tmp/install',
|
|
||||||
workspaceRoot: '/tmp/workspace',
|
|
||||||
logDir: '/tmp/log',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = OtoServerSocketRegistrationClient(
|
|
||||||
commandTypes: ['Shell'],
|
|
||||||
heartbeatInterval: const Duration(milliseconds: 200),
|
|
||||||
);
|
|
||||||
final session = await client.openSession(agentConfig);
|
|
||||||
expect(session.result.accepted, isTrue);
|
|
||||||
final pushSession = session as OtoServerPushJobSession;
|
|
||||||
|
|
||||||
final runReceived = Completer<String>();
|
|
||||||
final runSub = pushSession.runRequests.listen((request) {
|
|
||||||
if (!runReceived.isCompleted) {
|
|
||||||
expect(request.runnerId, _runnerId);
|
|
||||||
expect(request.jobId, jobId);
|
|
||||||
expect(request.executionId, isNotEmpty);
|
|
||||||
runReceived.complete(request.executionId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
var expectedExecutionId = '';
|
|
||||||
final cancelReceived = Completer<String>();
|
|
||||||
final cancelSub = pushSession.cancelRequests.listen((request) {
|
|
||||||
if (!cancelReceived.isCompleted) {
|
|
||||||
expect(request.runnerId, _runnerId);
|
|
||||||
expect(request.executionId, expectedExecutionId);
|
|
||||||
expect(request.reason, 'socket cancel smoke');
|
|
||||||
cancelReceived.complete(request.executionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final httpClient = http.Client();
|
|
||||||
final jobs = OtoServerJobClient(
|
|
||||||
serverUrl: 'http://$serverAddr',
|
|
||||||
runnerId: _runnerId,
|
|
||||||
client: httpClient,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
final createResp = await httpClient.post(
|
|
||||||
Uri.parse('http://$serverAddr/api/v1/jobs'),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
body: jsonEncode({
|
|
||||||
'id': jobId,
|
|
||||||
'name': 'socket cancel smoke',
|
|
||||||
'run_request': {
|
|
||||||
'pipeline_yaml': 'commands:\n - type: Shell',
|
|
||||||
'command_types': ['Shell'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(createResp.statusCode, equals(201));
|
|
||||||
|
|
||||||
expectedExecutionId = await runReceived.future.timeout(
|
|
||||||
const Duration(seconds: 3),
|
|
||||||
onTimeout: () => fail('socket RunRequest was not received'),
|
|
||||||
);
|
|
||||||
|
|
||||||
final cancelRes = await jobs.cancelRun(
|
|
||||||
executionId: expectedExecutionId,
|
|
||||||
reason: 'socket cancel smoke',
|
|
||||||
);
|
|
||||||
expect(cancelRes.success, isTrue);
|
|
||||||
|
|
||||||
await cancelReceived.future.timeout(
|
|
||||||
const Duration(seconds: 3),
|
|
||||||
onTimeout: () => fail('socket CancelRunRequest was not received'),
|
|
||||||
);
|
|
||||||
|
|
||||||
final execResp = await httpClient.get(
|
|
||||||
Uri.parse(
|
|
||||||
'http://$serverAddr/api/v1/executions/$expectedExecutionId',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final execJson = jsonDecode(execResp.body) as Map<String, dynamic>;
|
|
||||||
expect(execJson['state'], equals('canceled'));
|
|
||||||
} finally {
|
|
||||||
await runSub.cancel();
|
|
||||||
await cancelSub.cancel();
|
|
||||||
httpClient.close();
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
process.kill(ProcessSignal.sigterm);
|
|
||||||
await process.exitCode.timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
onTimeout: () {
|
|
||||||
process.kill(ProcessSignal.sigkill);
|
|
||||||
return process.exitCode;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await stdoutSub.cancel();
|
|
||||||
await stderrSub.cancel();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timeout: const Timeout(Duration(seconds: 30)),
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'OtoServerSocketRegistrationClient socket session close is idempotent after server disconnect',
|
|
||||||
() async {
|
|
||||||
final port = await _freePort();
|
|
||||||
final serverAddr = '$_host:$port';
|
|
||||||
|
|
||||||
final (:process, :socketAddr) = await _startCoreWithSocket(serverAddr);
|
|
||||||
|
|
||||||
final output = StringBuffer();
|
|
||||||
final stdoutSub = process.stdout
|
|
||||||
.transform(systemEncoding.decoder)
|
|
||||||
.listen(output.write, onError: output.write);
|
|
||||||
final stderrSub = process.stderr
|
|
||||||
.transform(systemEncoding.decoder)
|
|
||||||
.listen(output.write, onError: output.write);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _waitForPort(_host, port, process, output);
|
|
||||||
|
|
||||||
final agentConfig = AgentConfig(
|
|
||||||
agent: const AgentIdentityConfig(
|
|
||||||
id: _runnerId,
|
|
||||||
alias: _runnerAlias,
|
|
||||||
enrollmentToken: _token,
|
|
||||||
),
|
|
||||||
server: ServerConnectionConfig(
|
|
||||||
url: 'http://$serverAddr',
|
|
||||||
socketUrl: 'tcp://$socketAddr',
|
|
||||||
),
|
|
||||||
runtime: const AgentRuntimeConfig(
|
|
||||||
installDir: '/tmp/install',
|
|
||||||
workspaceRoot: '/tmp/workspace',
|
|
||||||
logDir: '/tmp/log',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = OtoServerSocketRegistrationClient(
|
|
||||||
commandTypes: ['Shell'],
|
|
||||||
// Short interval so in-flight heartbeats exercise the catch block.
|
|
||||||
heartbeatInterval: const Duration(milliseconds: 50),
|
|
||||||
);
|
|
||||||
|
|
||||||
final session = await client.openSession(agentConfig);
|
|
||||||
expect(session.result.accepted, isTrue);
|
|
||||||
|
|
||||||
// Graceful server shutdown: closes all proto-socket clients cleanly,
|
|
||||||
// sending EOF to the Dart side. In-flight heartbeat sendRequest calls
|
|
||||||
// receive StateError('connection closed'), which is swallowed by the
|
|
||||||
// _sendHeartbeat catch block.
|
|
||||||
process.kill(ProcessSignal.sigterm);
|
|
||||||
await process.exitCode.timeout(const Duration(seconds: 5));
|
|
||||||
|
|
||||||
// Allow the EOF to propagate and the transport to auto-close.
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
// session.close() must complete without throwing even though the
|
|
||||||
// proto-socket transport is already closed by the EOF handler.
|
|
||||||
await expectLater(session.close(), completes);
|
|
||||||
} finally {
|
|
||||||
process.kill(ProcessSignal.sigkill);
|
|
||||||
await process.exitCode.timeout(
|
|
||||||
const Duration(seconds: 2),
|
|
||||||
onTimeout: () => -1,
|
|
||||||
);
|
|
||||||
await stdoutSub.cancel();
|
|
||||||
await stderrSub.cancel();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timeout: const Timeout(Duration(seconds: 30)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Socket session lifecycle tests ──────────────────────────────────────────
|
// ─── Test helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
typedef _CoreWithSocket = ({Process process, String socketAddr});
|
typedef _CoreWithSocket = ({Process process, String socketAddr});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue