feat: openai workspace agent execution contract implementation

- Add workspace boundary enforcement for edge OpenAI API
- Update edge config with workspace settings
- Refactor catalog routing for workspace-scoped agents
- Move completed task to archive
- Update e2e scripts and documentation
This commit is contained in:
toki 2026-06-13 16:54:59 +09:00
parent a758926593
commit ca9fcd12a9
10 changed files with 220 additions and 39 deletions

View file

@ -42,41 +42,43 @@ task=m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_boun
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Route Catalog Workspace-Bound Flag | [ ] |
| [API-2] Config And Smoke Fixture Route Catalog Evidence | [ ] |
| [API-1] Route Catalog Workspace-Bound Flag | [x] |
| [API-2] Config And Smoke Fixture Route Catalog Evidence | [x] |
## 구현 체크리스트
- [ ] `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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `OpenAIRouteEntry`와 route dispatch에 workspace-bound agent route 표식을 추가하고 config load test를 갱신한다.
- [x] `configs/edge.yaml`와 OpenAI smoke fixture가 route catalog 기준을 보여주도록 갱신한다.
- [x] `go test ./packages/go/config ./apps/edge/internal/openai`와 `./scripts/e2e-openai-ollama.sh`를 실행한다.
- [x] 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이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] 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 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- `scripts/e2e-openai-ollama.sh`의 edge config에서 top-level `target`/`models` 대신 `model_routes`를 사용하도록 전환했다. 기존 `target: "$MODEL"`, `models: ["$MODEL"]` 설정을 제거하고 `model_routes` 두 항목(`client-request-model` → `$MODEL`, `$MODEL` → `$MODEL`)으로 대체했다. 기존 Chat/Responses/Stream/Smoke 요청은 모두 catalog dispatch 경로를 통해 동일하게 처리되므로 functional assertion은 변경 없다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `WorkspaceRequired`는 `OpenAIRouteEntry`에 `bool` scalar로 추가했고, `omitempty`를 적용해 false(기본값)는 YAML에 직렬화되지 않는다. proto field가 아닌 config schema 수준에서만 표시하므로 struct 추가만으로 충분하다.
- `routeDispatch.WorkspaceRequired`는 catalog match path에서만 복사되며, fallback path는 항상 zero value(false)를 유지한다. 후속 `workspace-required` enforcement Task가 이 값을 소비하는 구조를 그대로 전달한다.
- smoke fixture에서 Ollama route를 `workspace_required: true`로 두지 않았다. 계획 요구사항(Ollama smoke route는 workspace-required로 만들지 않는다)을 준수했다.
## 사용자 리뷰 요청
@ -110,20 +112,23 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### API-1 중간 검증
```bash
$ go test ./packages/go/config ./apps/edge/internal/openai
(output)
ok iop/packages/go/config 0.014s
ok iop/apps/edge/internal/openai 1.508s
```
### API-2 중간 검증
```bash
$ ./scripts/e2e-openai-ollama.sh
(output)
[openai-ollama] OpenAI-compatible Ollama serving test PASSED.
```
### 최종 검증
```bash
$ go test ./packages/go/config ./apps/edge/internal/openai
ok iop/packages/go/config 0.014s
ok iop/apps/edge/internal/openai 1.508s
$ ./scripts/e2e-openai-ollama.sh
(output)
[openai-ollama] OpenAI-compatible Ollama serving test PASSED.
```
---
@ -141,3 +146,20 @@ $ ./scripts/e2e-openai-ollama.sh
| 사용자 리뷰 요청 | 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. |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 검증 확인:
- `go test -count=1 ./packages/go/config ./apps/edge/internal/openai` PASS: `ok iop/packages/go/config 0.014s`, `ok iop/apps/edge/internal/openai 1.509s`
- `./scripts/e2e-openai-ollama.sh` PASS: `[openai-ollama] OpenAI-compatible Ollama serving test PASSED.`
- 다음 단계: PASS 절차로 active plan/review를 로그화하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,41 @@
# Complete - m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound
## 완료 일시
2026-06-13
## 요약
Route catalog workspace-bound flag 작업을 1회 리뷰 루프로 검증했고 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | `workspace_required` config/dispatch 보존, route catalog smoke fixture, README/config 예시가 계획 범위와 일치함 |
## 구현/정리 내용
- `OpenAIRouteEntry.WorkspaceRequired`와 `routeDispatch.WorkspaceRequired`를 추가해 catalog route의 workspace-bound 표식을 보존했다.
- config load test와 OpenAI route dispatch test로 true/default false/fallback false 동작을 확인했다.
- `configs/edge.yaml`, `apps/edge/README.md`, `scripts/e2e-openai-ollama.sh`가 route catalog 기반 예시와 smoke fixture를 보여주도록 정리했다.
## 최종 검증
- `go test -count=1 ./packages/go/config ./apps/edge/internal/openai` - PASS; `ok iop/packages/go/config 0.014s`, `ok iop/apps/edge/internal/openai 1.509s`
- `./scripts/e2e-openai-ollama.sh` - PASS; `[openai-ollama] OpenAI-compatible Ollama serving test PASSED.`
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-workspace-agent-execution-contract.md`
- Completed task ids:
- `route-catalog`: PASS; evidence=`agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-openai-workspace-agent-execution-contract/01_route_catalog_workspace_bound/code_review_local_G05_0.log`; verification=`go test -count=1 ./packages/go/config ./apps/edge/internal/openai`, `./scripts/e2e-openai-ollama.sh`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -193,6 +193,20 @@ nodes:
`openai.target`이 비어 있으면 HTTP 요청의 `model` 값을 내부 target으로 사용한다. 값이 있으면 Cline 같은 외부 agent가 보낸 `model`과 무관하게 YAML의 target으로 고정 라우팅한다.
`openai.model_routes`를 설정하면 외부 model id별로 adapter/target을 달리 지정할 수 있다. `/v1/models`는 catalog에 등록된 model id만 노출하고, 매칭된 route는 top-level adapter/target보다 우선한다. `workspace_required: true`는 CLI agent target처럼 호출자가 `metadata.workspace`를 반드시 제공해야 하는 route를 표시한다. fallback route(catalog 미매칭)는 항상 `workspace_required: false`다.
```yaml
openai:
model_routes:
- model: "codex"
adapter: "cli"
target: "codex"
workspace_required: true
- model: "llama3"
adapter: "ollama"
target: "llama3:8b"
```
`/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

View file

@ -155,11 +155,12 @@ func (s *Server) resolveTargetWithOverride(model, override string) string {
// routeDispatch holds fully-resolved dispatch parameters for a single request.
type routeDispatch struct {
NodeRef string
Adapter string
Target string
SessionID string
TimeoutSec int
NodeRef string
Adapter string
Target string
SessionID string
TimeoutSec int
WorkspaceRequired bool
}
// resolveRoute returns the first catalog entry whose Model matches model.
@ -201,11 +202,12 @@ func (s *Server) resolveRouteDispatch(model, metadataTarget string) (routeDispat
timeoutSec = s.resolveTimeoutSec()
}
return routeDispatch{
NodeRef: nodeRef,
Adapter: adapter,
Target: route.Target,
SessionID: sessionID,
TimeoutSec: timeoutSec,
NodeRef: nodeRef,
Adapter: adapter,
Target: route.Target,
SessionID: sessionID,
TimeoutSec: timeoutSec,
WorkspaceRequired: route.WorkspaceRequired,
}, true
}
target := s.resolveTargetWithOverride(model, metadataTarget)

View file

@ -1105,6 +1105,49 @@ func TestChatCompletionsEdgePassesNumCtxForAdapterEnforcement(t *testing.T) {
}
}
func TestResolveRouteDispatchPreservesWorkspaceRequired(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{
ModelRoutes: []config.OpenAIRouteEntry{
{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
{Model: "llama3", Adapter: "ollama", Target: "llama3:8b"},
},
}, &fakeRunService{}, nil)
dispatch, ok := srv.resolveRouteDispatch("codex", "")
if !ok {
t.Fatal("expected dispatch to succeed for codex route")
}
if !dispatch.WorkspaceRequired {
t.Fatalf("expected workspace_required=true for codex route, got false")
}
dispatch, ok = srv.resolveRouteDispatch("llama3", "")
if !ok {
t.Fatal("expected dispatch to succeed for llama3 route")
}
if dispatch.WorkspaceRequired {
t.Fatalf("expected workspace_required=false for llama3 route, got true")
}
}
func TestResolveRouteDispatchFallbackWorkspaceRequiredFalse(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{
Adapter: "ollama",
Target: "llama3",
ModelRoutes: []config.OpenAIRouteEntry{
{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
},
}, &fakeRunService{}, nil)
dispatch, ok := srv.resolveRouteDispatch("unknown-model", "")
if !ok {
t.Fatal("expected fallback dispatch to succeed")
}
if dispatch.WorkspaceRequired {
t.Fatalf("expected workspace_required=false for fallback route, got true")
}
}
func TestCollectRunResultFailsWhenEventStreamCloses(t *testing.T) {
events := make(chan *iop.RunEvent)
close(events)

View file

@ -56,7 +56,12 @@ openai:
# model_routes maps external model ids to internal adapter/target routing.
# When set, /v1/models exposes these ids and /v1/chat/completions + /v1/responses
# resolve adapter/target per-entry. Entries not matched fall back to target/adapter above.
# workspace_required: true marks CLI agent routes that require a workspace path from the caller.
# model_routes:
# - model: "codex"
# adapter: "cli"
# target: "codex"
# workspace_required: true
# - model: "llama3"
# adapter: "ollama"
# target: "llama3:8b"

View file

@ -88,12 +88,13 @@ type EdgeBootstrapConf struct {
// OpenAIRouteEntry maps an external model id to an internal adapter/target routing.
// Fields not set here fall back to the top-level EdgeOpenAIConf defaults.
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"`
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"`
}
type EdgeOpenAIConf struct {

View file

@ -1098,10 +1098,59 @@ openai:
if r0.NodeRef != "node-01" || r0.SessionID != "sess-a" || r0.TimeoutSec != 30 {
t.Errorf("route[0] optional fields mismatch: %+v", r0)
}
if r0.WorkspaceRequired {
t.Errorf("route[0] workspace_required should default to false: %+v", r0)
}
r1 := cfg.OpenAI.ModelRoutes[1]
if r1.Model != "model-b" || r1.Adapter != "vllm" || r1.Target != "qwen" {
t.Errorf("route[1] mismatch: %+v", r1)
}
if r1.WorkspaceRequired {
t.Errorf("route[1] workspace_required should default to false: %+v", r1)
}
}
func TestLoadEdge_OpenAIRouteCatalogWorkspaceRequired(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "edge.yaml")
yaml := `
server:
listen: "0.0.0.0:9090"
openai:
enabled: true
model_routes:
- model: "codex"
adapter: "cli"
target: "codex"
workspace_required: true
- model: "llama3"
adapter: "ollama"
target: "llama3:8b"
`
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg, err := config.LoadEdge(f)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cfg.OpenAI.ModelRoutes) != 2 {
t.Fatalf("expected 2 model_routes, got %d", len(cfg.OpenAI.ModelRoutes))
}
agent := cfg.OpenAI.ModelRoutes[0]
if agent.Model != "codex" || agent.Adapter != "cli" || agent.Target != "codex" {
t.Errorf("agent route mismatch: %+v", agent)
}
if !agent.WorkspaceRequired {
t.Errorf("agent route workspace_required should be true: %+v", agent)
}
inference := cfg.OpenAI.ModelRoutes[1]
if inference.Model != "llama3" || inference.Adapter != "ollama" || inference.Target != "llama3:8b" {
t.Errorf("inference route mismatch: %+v", inference)
}
if inference.WorkspaceRequired {
t.Errorf("inference route workspace_required should be false: %+v", inference)
}
}
func TestLoadEdge_OpenAIRouteCatalogDuplicateModelRejects(t *testing.T) {

View file

@ -124,12 +124,16 @@ openai:
enabled: true
listen: "127.0.0.1:$OPENAI_PORT"
adapter: "ollama"
target: "$MODEL"
models:
- "$MODEL"
session_id: "cline"
timeout_sec: 30
strict_output: false
model_routes:
- model: "client-request-model"
adapter: "ollama"
target: "$MODEL"
- model: "$MODEL"
adapter: "ollama"
target: "$MODEL"
console:
adapter: mock
target: mock-echo