feat: openai workspace agent execution contract milestone and edge updates

- Add milestone doc for openai-workspace-agent-execution-contract
- Update edge chat/responses handlers and types
- Update edge service run dispatch logic
- Add edge and service tests
- Update edge README
This commit is contained in:
toki 2026-06-13 16:33:00 +09:00
parent dd3572a0ef
commit a758926593
12 changed files with 805 additions and 25 deletions

View file

@ -1,4 +1,4 @@
# Milestone: OpenAI Workspace Agent Execution Contract
현재# Milestone: OpenAI Workspace Agent Execution Contract
## 위치

View file

@ -0,0 +1,143 @@
<!-- task=m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-13
task=m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `route-catalog`: 외부 `model` route가 내부 `adapter + target`으로 해석되는 기준을 config와 smoke fixture에 남긴다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Route Catalog Workspace-Bound Flag | [ ] |
| [API-2] Config And Smoke Fixture Route Catalog Evidence | [ ] |
## 구현 체크리스트
- [ ] `OpenAIRouteEntry`와 route dispatch에 workspace-bound agent route 표식을 추가하고 config load test를 갱신한다.
- [ ] `configs/edge.yaml`와 OpenAI smoke fixture가 route catalog 기준을 보여주도록 갱신한다.
- [ ] `go test ./packages/go/config ./apps/edge/internal/openai``./scripts/e2e-openai-ollama.sh`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_local_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_local_G05_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound/``agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md``CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `workspace_required`가 route catalog entry의 명시 필드이며 fallback route 동작을 바꾸지 않는지 확인한다.
- `scripts/e2e-openai-ollama.sh`가 route catalog를 실제로 사용하면서 workspace-required agent route로 오인되지 않는지 확인한다.
- Roadmap `route-catalog` Task 외 범위가 섞이지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ go test ./packages/go/config ./apps/edge/internal/openai
(output)
```
### API-2 중간 검증
```bash
$ ./scripts/e2e-openai-ollama.sh
(output)
```
### 최종 검증
```bash
$ go test ./packages/go/config ./apps/edge/internal/openai
$ ./scripts/e2e-openai-ollama.sh
(output)
```
---
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus areas from the plan. |
| 검증 결과 | Implementing agent | Paste actual stdout/stderr for the commands above. |

View file

@ -0,0 +1,198 @@
<!-- task=m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현이다. 코드를 수정하고 검증을 실행한 뒤 실제 변경 내용, 명령 출력, 계획 대비 변경 사항을 기록하고 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌로 막히면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 채운다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다.
## 배경
`metadata-schema` 선행 작업으로 `metadata.workspace`는 Edge service의 `RunRequest.Workspace`까지 전달된다. 남은 `route-catalog` Task는 외부 `model`이 내부 `adapter + target`으로 해석될 때 어떤 route가 workspace-bound agent target인지 config와 smoke fixture에 남겨야 한다. 이 표식이 있어야 후속 `workspace-required` Task가 inference route는 유지하면서 agent route에만 workspace 필수 조건을 적용할 수 있다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 결정이 필요하면 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 active `CODE_REVIEW-local-G05.md``사용자 리뷰 요청` 섹션을 채운다. 구현 에이전트는 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `route-catalog`: 외부 `model` route가 내부 `adapter + target`으로 해석되는 기준을 config와 smoke fixture에 남긴다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- `agent-contract/index.md`
- `agent-contract/provided/openai-compatible-api.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `packages/go/config/config.go`
- `packages/go/config/config_test.go`
- `configs/edge.yaml`
- `configs/edge-compose.yaml.tmpl`
- `scripts/e2e-openai-ollama.sh`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/edgecmd/smoke_openai.go`
### 테스트 환경 규칙
`test_env=local`을 선택했다. `agent-test/local/rules.md`를 읽었고, edge 변경 기준으로 `agent-test/local/edge-smoke.md`, config 변경 기준으로 `agent-test/local/platform-common-smoke.md`를 읽었다. 적용 명령은 `go test ./packages/go/config ./apps/edge/internal/openai`, 최종 smoke는 OpenAI 입력 표면 기준 `./scripts/e2e-openai-ollama.sh`다. 규칙 파일에 구조적 blank나 `<확인 필요>` 값은 없었다.
### 테스트 커버리지 공백
route catalog dispatch는 `apps/edge/internal/openai/server_test.go`에 있지만 workspace-bound route 표식은 현재 없다. config load는 `packages/go/config/config_test.go`에 route catalog test가 있지만 `workspace_required` field coverage가 없다. `scripts/e2e-openai-ollama.sh`는 현재 top-level `target/models` fallback을 주로 쓰므로 route catalog smoke fixture coverage가 약하다.
### 심볼 참조
renamed/removed symbols: none.
### 분할 판단
split decision policy를 평가했다. 이 task group은 `agent-task/m-openai-workspace-agent-execution-contract/`이고 sibling은 `01_route_catalog_workspace_bound``02+01_workspace_required_enforcement`다. `01_route_catalog_workspace_bound`는 선행 config/fixture 계약 작업이며 dependency가 없다. `02+01_workspace_required_enforcement`는 route catalog 표식을 소비하므로 predecessor `01``complete.log`가 필요하다.
### 범위 결정 근거
이 계획은 route catalog에 workspace-bound 표식을 추가하고 config/smoke fixture에 남기는 데 한정한다. `metadata.workspace` 파싱은 이미 작은 선행 변경으로 처리되었으므로 확장하지 않는다. workspace 누락/상대 경로 거부는 후속 `02+01_workspace_required_enforcement`가 맡는다. Node cwd 적용, session workspace isolation, NomadCode 호출 shape는 다른 Epic/Task 범위라 제외한다.
### 빌드 등급
build lane은 `local-G05`다. config schema, YAML fixture, focused Go tests로 검증 가능하지만 public config 계약에 닿아 중간 등급으로 둔다.
## 구현 체크리스트
- [ ] `OpenAIRouteEntry`와 route dispatch에 workspace-bound agent route 표식을 추가하고 config load test를 갱신한다.
- [ ] `configs/edge.yaml`와 OpenAI smoke fixture가 route catalog 기준을 보여주도록 갱신한다.
- [ ] `go test ./packages/go/config ./apps/edge/internal/openai``./scripts/e2e-openai-ollama.sh`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Route Catalog Workspace-Bound Flag
문제: `packages/go/config/config.go:90``OpenAIRouteEntry`는 external model을 adapter/target으로 매핑하지만 workspace-bound agent target 여부를 표현하지 못한다. `apps/edge/internal/openai/chat_handler.go:156``routeDispatch`도 이 정보를 후속 검증 로직에 전달할 필드가 없다.
해결 방법: `workspace_required` YAML/mapstructure field를 `OpenAIRouteEntry`에 추가하고, route catalog match path에서 `routeDispatch.WorkspaceRequired`로 복사한다. fallback route는 `false`를 유지한다.
Before:
```go
// packages/go/config/config.go:90
type OpenAIRouteEntry struct {
Model string `mapstructure:"model" yaml:"model"`
NodeRef string `mapstructure:"node" yaml:"node,omitempty"`
Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"`
Target string `mapstructure:"target" yaml:"target"`
SessionID string `mapstructure:"session_id" yaml:"session_id,omitempty"`
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec,omitempty"`
}
```
After:
```go
type OpenAIRouteEntry struct {
Model string `mapstructure:"model" yaml:"model"`
NodeRef string `mapstructure:"node" yaml:"node,omitempty"`
Adapter string `mapstructure:"adapter" yaml:"adapter,omitempty"`
Target string `mapstructure:"target" yaml:"target"`
SessionID string `mapstructure:"session_id" yaml:"session_id,omitempty"`
TimeoutSec int `mapstructure:"timeout_sec" yaml:"timeout_sec,omitempty"`
WorkspaceRequired bool `mapstructure:"workspace_required" yaml:"workspace_required,omitempty"`
}
```
수정 파일 및 체크리스트:
- [ ] `packages/go/config/config.go`: `OpenAIRouteEntry.WorkspaceRequired` 추가.
- [ ] `packages/go/config/config_test.go`: `TestLoadEdge_OpenAIRouteCatalog``workspace_required: true` fixture와 assertion 추가.
- [ ] `apps/edge/internal/openai/chat_handler.go`: `routeDispatch.WorkspaceRequired` 추가, catalog match에서 복사, fallback false 유지.
- [ ] `apps/edge/internal/openai/server_test.go`: route dispatch가 `workspace_required`를 보존하는 package-level test 추가.
테스트 작성: 작성한다. config load test는 YAML parsing을, openai package test는 dispatch DTO 보존을 검증한다.
중간 검증:
```bash
go test ./packages/go/config ./apps/edge/internal/openai
```
기대 결과: 두 패키지 모두 PASS.
### [API-2] Config And Smoke Fixture Route Catalog Evidence
문제: `configs/edge.yaml:56``model_routes` 예시는 inference provider route만 보여주고 `model: "codex"` 같은 workspace-bound agent route 기준을 남기지 않는다. `scripts/e2e-openai-ollama.sh:123`의 smoke config는 `target/models` fallback 중심이라 route catalog smoke evidence가 약하다.
해결 방법: `configs/edge.yaml` 주석 예시에 CLI agent route를 추가하고 `workspace_required: true`를 명시한다. `scripts/e2e-openai-ollama.sh`의 임시 config에 `model_routes`를 추가해 `/v1/models`, Chat Completions, Responses가 catalog route를 통과하게 하되 Ollama smoke route는 workspace-required로 만들지 않는다.
Before:
```yaml
# configs/edge.yaml:59
# model_routes:
# - model: "llama3"
# adapter: "ollama"
# target: "llama3:8b"
```
After:
```yaml
# model_routes:
# - model: "codex"
# adapter: "cli"
# target: "codex"
# workspace_required: true
# - model: "llama3"
# adapter: "ollama"
# target: "llama3:8b"
```
수정 파일 및 체크리스트:
- [ ] `configs/edge.yaml`: codex workspace-bound route 예시 추가.
- [ ] `scripts/e2e-openai-ollama.sh`: temp `openai.model_routes` fixture 추가, model assertions/request model이 catalog route를 검증하도록 조정.
- [ ] `apps/edge/README.md`: route catalog 설명이 `workspace_required`를 언급하도록 필요한 최소 문장만 갱신.
테스트 작성: 별도 단위 테스트보다 기존 smoke script를 route catalog fixture로 바꾸는 것이 검증이다.
중간 검증:
```bash
./scripts/e2e-openai-ollama.sh
```
기대 결과: `OpenAI-compatible Ollama serving test PASSED.` 출력.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `packages/go/config/config.go` | API-1 |
| `packages/go/config/config_test.go` | API-1 |
| `apps/edge/internal/openai/chat_handler.go` | API-1 |
| `apps/edge/internal/openai/server_test.go` | API-1 |
| `configs/edge.yaml` | API-2 |
| `scripts/e2e-openai-ollama.sh` | API-2 |
| `apps/edge/README.md` | API-2 |
## 최종 검증
```bash
go test ./packages/go/config ./apps/edge/internal/openai
./scripts/e2e-openai-ollama.sh
```
Go test cache output is acceptable for unchanged packages; if implementation changes test-sensitive logic and fresh execution is needed, use `go test -count=1 ./packages/go/config ./apps/edge/internal/openai` and record the substitution.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,144 @@
<!-- task=m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-13
task=m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `workspace-required`: 내부 실행 route가 workspace-bound agent target이면 workspace가 비어 있거나 상대 경로일 때 OpenAI-compatible error로 거부한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Workspace-Required Validation Helper | [ ] |
| [API-2] Endpoint Regression Tests For Required And Non-Required Routes | [ ] |
## 구현 체크리스트
- [ ] predecessor `01_route_catalog_workspace_bound``complete.log`가 active 또는 archive 위치에 있는지 확인한다.
- [ ] workspace-bound route에서 workspace가 빈 값 또는 상대 경로이면 `/v1/responses``/v1/chat/completions` 모두 OpenAI-compatible 400 error를 반환하게 한다.
- [ ] workspace가 필요 없는 inference/fallback route는 기존 동작을 유지하는 회귀 test를 추가한다.
- [ ] `go test ./apps/edge/internal/openai ./apps/edge/internal/service``./scripts/e2e-openai-ollama.sh`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_local_G06_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_local_G06_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/``agent-task/archive/YYYY/MM/m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-workspace-agent-execution-contract/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md``CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- predecessor `01_route_catalog_workspace_bound`의 completion evidence 없이 구현되지 않았는지 확인한다.
- workspace-required route만 400을 반환하고 inference/fallback route는 기존 동작을 유지하는지 확인한다.
- workspace 검증이 existence/permission/cwd 적용 범위까지 확장되지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ go test ./apps/edge/internal/openai
(output)
```
### API-2 중간 검증
```bash
$ go test ./apps/edge/internal/openai ./apps/edge/internal/service
(output)
```
### 최종 검증
```bash
$ go test ./apps/edge/internal/openai ./apps/edge/internal/service
$ ./scripts/e2e-openai-ollama.sh
(output)
```
---
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only. |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory before saving. |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed. |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus areas from the plan. |
| 검증 결과 | Implementing agent | Paste actual stdout/stderr for the commands above. |

View file

@ -0,0 +1,189 @@
<!-- task=m-openai-workspace-agent-execution-contract/02+01_workspace_required_enforcement plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
이 plan은 split dependency가 있다. 구현을 시작하기 전에 같은 task group의 `01_route_catalog_workspace_bound/complete.log`가 존재하는지 확인한다. `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현이며, 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 사용자 결정, 사용자 소유 외부 환경, scope 충돌로 막히면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 채운다.
## 배경
`metadata.workspace`는 Edge service의 `RunRequest.Workspace`로 전달되도록 선행 작은 변경에서 처리되었다. 하지만 workspace-bound agent route와 일반 inference route를 구분하지 않으면 workspace 누락 검증을 안전하게 적용할 수 없다. 이 작업은 route catalog의 workspace-bound 표식을 소비해서 agent route에만 workspace 필수 조건을 적용한다.
## 사용자 리뷰 요청 흐름
구현 중 사용자 결정이 필요하면 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 active `CODE_REVIEW-local-G06.md``사용자 리뷰 요청` 섹션을 채운다. 구현 에이전트는 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Task ids:
- `workspace-required`: 내부 실행 route가 workspace-bound agent target이면 workspace가 비어 있거나 상대 경로일 때 OpenAI-compatible error로 거부한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- `agent-contract/index.md`
- `agent-contract/provided/openai-compatible-api.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service_test.go`
- `packages/go/config/config.go`
- `configs/edge.yaml`
- `scripts/e2e-openai-ollama.sh`
### 테스트 환경 규칙
`test_env=local`을 선택했다. `agent-test/local/rules.md`를 읽었고, edge 변경 기준 `agent-test/local/edge-smoke.md`, config 연동 기준 `agent-test/local/platform-common-smoke.md`를 읽었다. 적용 명령은 `go test ./apps/edge/internal/openai ./apps/edge/internal/service`, OpenAI boundary smoke는 `./scripts/e2e-openai-ollama.sh`다. 규칙 파일에 구조적 blank나 `<확인 필요>` 값은 없었다.
### 테스트 커버리지 공백
OpenAI handler tests는 route dispatch와 metadata 전달을 검증하지만 workspace-bound route의 empty/relative workspace rejection은 아직 없다. 일반 inference route가 workspace 없이 계속 통과해야 하는 회귀 test도 명시적으로 추가해야 한다. 실제 Node cwd 적용은 이 Task 범위가 아니므로 이 계획에서는 검증하지 않는다.
### 심볼 참조
renamed/removed symbols: none.
### 분할 판단
split decision policy를 평가했다. 이 task group은 `agent-task/m-openai-workspace-agent-execution-contract/`이고 sibling은 `01_route_catalog_workspace_bound``02+01_workspace_required_enforcement`다. 이 plan은 dependent subtask이며 predecessor index `01`은 현재 active plan 상태이고 `complete.log`가 아직 없다. 구현 시작 전 `agent-task/m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound/complete.log` 또는 matching archive `complete.log`로 dependency 충족을 확인해야 한다.
### 범위 결정 근거
이 계획은 OpenAI-compatible request validation에 한정한다. route catalog field 생성은 predecessor task 범위다. Node CLI adapter cwd 적용, session workspace isolation, nonexistent path/permission failure mapping은 `edge-node-workspace``nomad-http-smoke` Epic 쪽 Task로 남긴다. `metadata.workspace`를 prompt 본문이나 `metadata` map으로 복제하지 않는다.
### 빌드 등급
build lane은 `local-G06`이다. HTTP error behavior가 바뀌고 route catalog 선행 작업에 의존하지만, 변경 범위가 OpenAI handler와 focused tests에 묶인다.
## 구현 체크리스트
- [ ] predecessor `01_route_catalog_workspace_bound``complete.log`가 active 또는 archive 위치에 있는지 확인한다.
- [ ] workspace-bound route에서 workspace가 빈 값 또는 상대 경로이면 `/v1/responses``/v1/chat/completions` 모두 OpenAI-compatible 400 error를 반환하게 한다.
- [ ] workspace가 필요 없는 inference/fallback route는 기존 동작을 유지하는 회귀 test를 추가한다.
- [ ] `go test ./apps/edge/internal/openai ./apps/edge/internal/service``./scripts/e2e-openai-ollama.sh`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
이 directory 이름 `02+01_workspace_required_enforcement`의 predecessor는 `01_route_catalog_workspace_bound`다. 구현 전 `01``complete.log`를 확인한다. 없으면 구현하지 말고 `CODE_REVIEW-local-G06.md``사용자 리뷰 요청`이 아니라 계획 미충족 상태로 보고한다.
### [API-1] Workspace-Required Validation Helper
문제: `apps/edge/internal/openai/responses_handler.go:45`는 workspace를 파싱하고 `apps/edge/internal/openai/responses_handler.go:82`에서 service로 전달하지만 workspace가 route 요구 조건을 만족하는지 검증하지 않는다. `apps/edge/internal/openai/chat_handler.go:30`과 submit path도 같은 상태다.
해결 방법: predecessor가 추가한 `routeDispatch.WorkspaceRequired`를 기준으로 helper를 만든다. `WorkspaceRequired=false`면 no-op이다. `true`일 때 `strings.TrimSpace(workspace)==""`이면 `workspace is required for this model route`, `!filepath.IsAbs(workspace)`이면 `workspace must be an absolute path` 같은 OpenAI-compatible 400 error를 반환한다.
Before:
```go
// apps/edge/internal/openai/responses_handler.go:51
dispatch, ok := s.resolveRouteDispatch(req.Model, inferenceTarget)
if !ok {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
```
After:
```go
dispatch, ok := s.resolveRouteDispatch(req.Model, inferenceTarget)
if !ok {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
```
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/openai/chat_handler.go`: dispatch resolve 뒤 workspace validation 호출.
- [ ] `apps/edge/internal/openai/responses_handler.go`: dispatch resolve 뒤 workspace validation 호출.
- [ ] `apps/edge/internal/openai/chat_handler.go` 또는 새 small file: `validateWorkspaceForRoute` helper 추가. `path/filepath``strings` import를 정리한다.
테스트 작성: 작성한다. helper 단독 test보다 handler tests로 HTTP status와 service dispatch 여부를 검증한다.
중간 검증:
```bash
go test ./apps/edge/internal/openai
```
기대 결과: openai package PASS.
### [API-2] Endpoint Regression Tests For Required And Non-Required Routes
문제: workspace-bound agent route만 workspace를 요구해야 한다. 기존 inference route, top-level `openai.target`, metadata target fallback은 workspace 없이도 동작해야 한다.
해결 방법: `apps/edge/internal/openai/server_test.go``/v1/responses``/v1/chat/completions` test를 추가한다. route catalog entry `{Model:"codex", Adapter:"cli", Target:"codex", WorkspaceRequired:true}`에서 missing/relative workspace는 400이고 service `SubmitRun`은 호출되지 않아야 한다. absolute workspace는 OK이며 `fake.req.Workspace`에 보존되어야 한다. non-required Ollama route는 workspace 없이 OK임을 확인한다.
Before:
```go
// apps/edge/internal/openai/server_test.go:1029
func TestResponsesRouteCatalogDispatchesRoute(t *testing.T) {
// route dispatch만 검증하고 workspace-required error는 검증하지 않는다.
}
```
After:
```go
func TestResponsesWorkspaceRequiredRouteRequiresAbsoluteWorkspace(t *testing.T) {
// missing workspace -> 400
// relative workspace -> 400
// absolute workspace -> OK and fake.req.Workspace is preserved
}
```
수정 파일 및 체크리스트:
- [ ] `apps/edge/internal/openai/server_test.go`: Responses missing/relative/absolute workspace cases 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: Chat Completions missing/relative/absolute workspace cases 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: non-required route 또는 fallback route가 workspace 없이 OK인 회귀 assertion 추가.
테스트 작성: 작성한다. public HTTP behavior 변경이므로 regression tests가 필수다.
중간 검증:
```bash
go test ./apps/edge/internal/openai ./apps/edge/internal/service
```
기대 결과: 두 패키지 모두 PASS.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/chat_handler.go` | API-1 |
| `apps/edge/internal/openai/responses_handler.go` | API-1 |
| `apps/edge/internal/openai/server_test.go` | API-2 |
## 최종 검증
```bash
go test ./apps/edge/internal/openai ./apps/edge/internal/service
./scripts/e2e-openai-ollama.sh
```
Go test cache output is acceptable for unchanged packages; for the OpenAI handler package prefer fresh execution with `go test -count=1 ./apps/edge/internal/openai` if handler tests were edited.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -193,7 +193,7 @@ nodes:
`openai.target`이 비어 있으면 HTTP 요청의 `model` 값을 내부 target으로 사용한다. 값이 있으면 Cline 같은 외부 agent가 보낸 `model`과 무관하게 YAML의 target으로 고정 라우팅한다.
`/v1/chat/completions`는 기본 non-streaming과 streaming SSE 응답을 모두 지원한다. `/v1/responses`는 현재 non-streaming 요청만 지원하며, `metadata.request_id`, `metadata.inference.target`, `metadata.nomadcode.task_id`, `metadata.nomadcode.source`를 service boundary까지 전달한다. NomadCode compatibility alias로 root-level `metadata.task_id``metadata.source`도 받아 각각 `nomadcode.task_id`, `nomadcode.source`로 매핑하며, structured `metadata.nomadcode.*` 값이 있으면 그 값을 우선한다.
`/v1/chat/completions`는 기본 non-streaming과 streaming SSE 응답을 모두 지원한다. `/v1/responses`는 현재 non-streaming 요청만 지원한다. 두 endpoint 모두 `metadata.workspace`를 run workspace로 전달하고, `metadata.request_id`, `metadata.inference.target`, `metadata.nomadcode.task_id`, `metadata.nomadcode.source`를 service boundary까지 전달한다. NomadCode compatibility alias로 root-level `metadata.task_id``metadata.source`도 받아 각각 `nomadcode.task_id`, `nomadcode.source`로 매핑하며, structured `metadata.nomadcode.*` 값이 있으면 그 값을 우선한다. `metadata.cli`는 지원하지 않는다.
```bash
curl -s http://127.0.0.1:18081/v1/chat/completions \
@ -204,7 +204,7 @@ curl -s http://127.0.0.1:18081/v1/chat/completions \
```bash
curl -s http://127.0.0.1:18081/v1/responses \
-H 'Content-Type: application/json' \
-d '{"model":"qwen3.6:35b-a3b-bf16","input":"hello","stream":false,"metadata":{"request_id":"example","nomadcode":{"task_id":"task-123","source":"manual"}}}'
-d '{"model":"qwen3.6:35b-a3b-bf16","input":"hello","stream":false,"metadata":{"request_id":"example","workspace":"/config/workspace/iop","nomadcode":{"task_id":"task-123","source":"manual"}}}'
```
vLLM은 같은 `adapter + target` 경계를 쓰도록 설정과 `/v1/models` capability 조회 skeleton만 먼저 열어두었다. 실제 completion 실행은 다음 단계에서 붙인다.

View file

@ -27,7 +27,13 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_request_error", "invalid JSON request")
return
}
dispatch, ok := s.resolveRouteDispatch(req.Model, "")
runMeta, inferenceTarget, workspace, err := parseOpenAIMetadata(req.Metadata)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
dispatch, ok := s.resolveRouteDispatch(req.Model, inferenceTarget)
if !ok {
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
return
@ -64,15 +70,11 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Workspace: workspace,
Prompt: prompt,
Input: input,
TimeoutSec: dispatch.TimeoutSec,
Metadata: map[string]string{
"source": "openai",
"openai_model": req.Model,
"openai_stream": fmt.Sprintf("%t", req.Stream),
"strict_output": fmt.Sprintf("%t", outputPolicy.Strict),
},
Metadata: chatRunMetadata(runMeta, req, outputPolicy),
})
if err != nil {
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
@ -87,6 +89,17 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
s.completeChatCompletion(w, r, req, handle, outputPolicy)
}
func chatRunMetadata(runMeta map[string]string, req chatCompletionRequest, outputPolicy strictOutputPolicy) map[string]string {
if runMeta == nil {
runMeta = make(map[string]string)
}
runMeta["source"] = "openai"
runMeta["openai_model"] = req.Model
runMeta["openai_stream"] = fmt.Sprintf("%t", req.Stream)
runMeta["strict_output"] = fmt.Sprintf("%t", outputPolicy.Strict)
return runMeta
}
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
text, reasoning, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
if err != nil {

View file

@ -42,7 +42,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
prompt = instruction + "\n" + prompt
}
runMeta, inferenceTarget, err := parseResponsesMetadata(req.Metadata)
runMeta, inferenceTarget, workspace, err := parseOpenAIMetadata(req.Metadata)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
@ -79,6 +79,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
Adapter: dispatch.Adapter,
Target: dispatch.Target,
SessionID: dispatch.SessionID,
Workspace: workspace,
Prompt: prompt,
Input: input,
TimeoutSec: dispatch.TimeoutSec,
@ -156,23 +157,23 @@ func buildResponsesPrompt(instructions, input string) string {
return instructions + "\n" + input
}
func parseResponsesMetadata(raw json.RawMessage) (map[string]string, string, error) {
func parseOpenAIMetadata(raw json.RawMessage) (map[string]string, string, string, error) {
if len(raw) == 0 || string(raw) == "null" {
return make(map[string]string), "", nil
return make(map[string]string), "", "", nil
}
var rawMap map[string]json.RawMessage
if err := json.Unmarshal(raw, &rawMap); err != nil {
return nil, "", fmt.Errorf("metadata must be an object")
return nil, "", "", fmt.Errorf("metadata must be an object")
}
if _, hasCLI := rawMap["cli"]; hasCLI {
return nil, "", fmt.Errorf("metadata.cli is not supported")
return nil, "", "", fmt.Errorf("metadata.cli is not supported")
}
var meta responsesMetadata
if err := json.Unmarshal(raw, &meta); err != nil {
return nil, "", fmt.Errorf("invalid metadata format")
return nil, "", "", fmt.Errorf("invalid metadata format")
}
flat := meta.metadataForRun()
@ -181,5 +182,5 @@ func parseResponsesMetadata(raw json.RawMessage) (map[string]string, string, err
inferenceTarget = meta.Inference.Target
}
return flat, inferenceTarget, nil
return flat, inferenceTarget, strings.TrimSpace(meta.Workspace), nil
}

View file

@ -526,6 +526,7 @@ func TestResponsesMetadataContractAndTargetOverride(t *testing.T) {
"input":"test",
"metadata":{
"request_id":"req-001",
"workspace":"/config/workspace/iop",
"inference":{"target":"metadata-target"},
"nomadcode":{"task_id":"task-123","source":"nomadcode"}
}
@ -539,9 +540,15 @@ func TestResponsesMetadataContractAndTargetOverride(t *testing.T) {
if fake.req.Target != "metadata-target" {
t.Fatalf("target: got %q, want metadata-target", fake.req.Target)
}
if fake.req.Workspace != "/config/workspace/iop" {
t.Fatalf("workspace: got %q", fake.req.Workspace)
}
if fake.req.Metadata["request_id"] != "req-001" {
t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
}
if _, ok := fake.req.Metadata["workspace"]; ok {
t.Fatal("workspace should not be copied into run metadata")
}
if fake.req.Metadata["inference.target"] != "metadata-target" {
t.Fatalf("inference.target: got %q", fake.req.Metadata["inference.target"])
}
@ -565,6 +572,68 @@ func TestResponsesMetadataContractAndTargetOverride(t *testing.T) {
}
}
func TestChatCompletionsMetadataContractAndWorkspace(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"client-model",
"messages":[{"role":"user","content":"hi"}],
"metadata":{
"request_id":"req-chat-001",
"workspace":"/config/workspace/iop",
"inference":{"target":"metadata-target"},
"nomadcode":{"task_id":"task-123","source":"nomadcode"}
}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if fake.req.Target != "metadata-target" {
t.Fatalf("target: got %q, want metadata-target", fake.req.Target)
}
if fake.req.Workspace != "/config/workspace/iop" {
t.Fatalf("workspace: got %q", fake.req.Workspace)
}
if fake.req.Metadata["request_id"] != "req-chat-001" {
t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
}
if fake.req.Metadata["inference.target"] != "metadata-target" {
t.Fatalf("inference.target: got %q", fake.req.Metadata["inference.target"])
}
if fake.req.Metadata["nomadcode.task_id"] != "task-123" {
t.Fatalf("nomadcode.task_id: got %q", fake.req.Metadata["nomadcode.task_id"])
}
if fake.req.Metadata["nomadcode.source"] != "nomadcode" {
t.Fatalf("nomadcode.source: got %q", fake.req.Metadata["nomadcode.source"])
}
if fake.req.Metadata["source"] != "openai" {
t.Fatalf("source: got %q", fake.req.Metadata["source"])
}
if _, ok := fake.req.Metadata["workspace"]; ok {
t.Fatal("workspace should not be copied into run metadata")
}
}
func TestChatCompletionsRejectsCLIMetadata(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"m",
"messages":[{"role":"user","content":"hi"}],
"metadata":{"cli":{"flag":"x"}}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
}
func TestResponsesAcceptsFlatNomadCodeMetadataAliases(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}

View file

@ -6,14 +6,15 @@ import (
)
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
Options map[string]any `json:"options,omitempty"`
Format any `json:"format,omitempty"`
KeepAlive any `json:"keep_alive,omitempty"`
Think any `json:"think,omitempty"`
Tools []any `json:"tools,omitempty"`
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Stream bool `json:"stream"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Options map[string]any `json:"options,omitempty"`
Format any `json:"format,omitempty"`
KeepAlive any `json:"keep_alive,omitempty"`
Think any `json:"think,omitempty"`
Tools []any `json:"tools,omitempty"`
}
type chatMessage struct {
@ -212,6 +213,7 @@ type responsesRequest struct {
type responsesMetadata struct {
RequestID string `json:"request_id,omitempty"`
Workspace string `json:"workspace,omitempty"`
TaskID string `json:"task_id,omitempty"`
Source string `json:"source,omitempty"`
Inference *responsesInferenceMetadata `json:"inference,omitempty"`

View file

@ -18,6 +18,7 @@ type SubmitRunRequest struct {
Adapter string
Target string
SessionID string
Workspace string
Prompt string
Input map[string]any
Background bool
@ -259,6 +260,7 @@ func BuildRunRequest(req SubmitRunRequest) (*iop.RunRequest, string, error) {
RunId: runID,
Adapter: req.Adapter,
Target: req.Target,
Workspace: req.Workspace,
SessionId: NormalizeSessionID(req.SessionID),
SessionMode: iop.RunSessionMode_RUN_SESSION_MODE_CREATE_IF_MISSING,
Background: req.Background,

View file

@ -183,6 +183,25 @@ func TestBuildRunRequestPreservesResponsesMetadataKeys(t *testing.T) {
}
}
func TestBuildRunRequestPreservesWorkspace(t *testing.T) {
req, _, err := edgeservice.BuildRunRequest(edgeservice.SubmitRunRequest{
Adapter: "cli",
Target: "codex",
Workspace: "/config/workspace/iop",
Prompt: "test",
Metadata: map[string]string{"request_id": "req-001"},
})
if err != nil {
t.Fatalf("BuildRunRequest: %v", err)
}
if req.GetWorkspace() != "/config/workspace/iop" {
t.Fatalf("Workspace: got %q", req.GetWorkspace())
}
if _, ok := req.GetMetadata()["workspace"]; ok {
t.Fatal("workspace should be preserved as RunRequest.Workspace, not metadata")
}
}
func TestListNodesReturnsSnapshots(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alpha"})