<!-- task=m-proto-socket-infrastructure-communication-rail/02+01_task_channels plan=0 tag=TASK-CHANNELS -->

# Plan - TASK-CHANNELS

## 이 파일을 읽는 구현 에이전트에게

구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증 명령을 실행하고 실제 stdout/stderr를 남긴 뒤 active 파일을 그대로 둔 채 리뷰 준비 상태를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.

구현 중 사용자만 결정할 수 있는 사항, 외부 환경 준비, 또는 계획 범위 충돌로 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정 필요 사항, 근거, 실행한 명령과 출력, 재개 조건을 채우고 중단한다. 구현 에이전트는 `USER_REVIEW.md`, archive log, `complete.log`를 직접 만들지 않는다.

## 배경

`01_core_endpoint`가 Core에 proto-socket endpoint와 envelope/dispatcher 기반을 만든 뒤, 두 번째 구현 단위는 기존 REST task API와 같은 의미의 request/response를 proto-socket action으로 제공하는 것이다. Task execution status는 이미 scheduler가 notification service로 event를 내보내므로, notification fanout에 proto-socket broadcaster를 붙이면 client-core rail의 실시간 상태 반영 기반이 생긴다.

## 사용자 리뷰 요청 흐름

구현 중 차단 사항은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 내용을 사용하며, code-review가 검증한 뒤 실제 `USER_REVIEW.md` 작성 여부를 결정한다.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/scenarios/core-smoke.md`
- `agent-test/local/scenarios/contracts-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/workflow-core/PHASE.md`
- `agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md`
- `packages/contracts/notes/flutter-core-api-candidates.md`
- `services/core/cmd/server/main.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/handlers_test.go`
- `services/core/internal/workflow/model.go`
- `services/core/internal/workflow/service.go`
- `services/core/internal/storage/store.go`
- `services/core/internal/db/models.go`
- `services/core/internal/notification/model.go`
- `services/core/internal/notification/service.go`
- `services/core/internal/notification/service_test.go`
- `services/core/internal/scheduler/jobs.go`
- `services/core/internal/scheduler/jobs_test.go`
- `../proto-socket/go/communicator.go`
- `../proto-socket/go/ws_client.go`
- `../proto-socket/go/test/ws_test.go`

### 테스트 커버리지 공백

- task proto-socket actions: 기존 `handlers_test.go`는 REST handler 중심이며 proto-socket request/response 경로가 없다.
- task error mapping: REST `writeServiceError`는 `workflow.ErrInvalidTaskInput`, `workflow.ErrTaskCannotBeEnqueued`, `pgx.ErrNoRows`를 HTTP status로 매핑하지만 proto-socket error code mapping 테스트가 없다.
- notification fanout: 기존 `notification/service_test.go`는 log와 non-completed Mattermost skip만 확인한다. status event sink fanout, sink error join, completed Mattermost 유지 검증이 없다.
- scheduler status event: `jobs_test.go`는 running/completed/failed event log를 확인하지만 proto-socket broadcaster로 전달되는지는 확인하지 않는다.

### 심볼 참조

- 제거/rename 대상 없음.
- `notification.NewService`는 variadic sink 인자를 추가해 기존 call site 호환을 유지한다. 현재 참조: `services/core/cmd/server/main.go:56`, `services/core/internal/notification/service_test.go:49`, `services/core/internal/notification/service_test.go:86`, `services/core/internal/scheduler/jobs_test.go:361`, `services/core/internal/scheduler/jobs_test.go:414`, 정의 `services/core/internal/notification/service.go:15`.
- `NotifyTaskEvent` 의미가 fanout 포함으로 확장된다. 현재 참조: `services/core/internal/notification/service.go:22-32`, `services/core/internal/notification/service_test.go:88`, `services/core/internal/scheduler/jobs.go:315`.

### 분할 판단

Split decision policy를 plan 파일 선택 전에 평가했다. 공유 task group은 `m-proto-socket-infrastructure-communication-rail`이다.

- `01_core_endpoint`: predecessor. Core endpoint, lifecycle, envelope/dispatcher skeleton을 만든다.
- `02+01_task_channels`: 이 plan. Directory name의 `+01`에 따라 `01_core_endpoint`의 `complete.log`가 필요하다.

Task channels는 workflow API mapping, notification fanout, event broadcast를 함께 다루므로 endpoint foundation과 분리한다. 단, 이 plan 안에서는 task request actions와 task status event가 같은 user-facing channel contract라서 함께 리뷰하는 편이 안전하다.

### 범위 결정 근거

- Flutter client consumption은 후속 `[client-rail]` Epic으로 남긴다.
- REST task API는 smoke/compat로 유지하고 삭제하지 않는다.
- Plane import channel(`workitem.plane.import`)은 REST compatibility map 후보지만 이번 plan은 Milestone task가 명시한 task create/list/get/enqueue/status event만 구현한다.
- DB schema, sqlc queries, scheduler queue behavior는 변경하지 않는다.
- `01_core_endpoint`에서 생성될 `internal/protosocket` 파일은 이 plan 작성 시점에는 존재하지 않는다. 구현 시작 전 predecessor `complete.log`와 실제 생성 파일을 먼저 읽고, 계획과 다른 public surface가 있으면 `계획 대비 변경 사항`에 기록한다.

### 빌드 등급

`cloud-G08`. Workflow API를 protocol action으로 매핑하고 notification fanout/concurrent broadcast/error handling을 추가하므로 protocol, concurrency, auth-adjacent, cross-package risk가 있다.

## 구현 체크리스트

- [ ] `01_core_endpoint`가 PASS되어 `complete.log`가 존재하는지 확인하고 predecessor가 만든 `internal/protosocket` 파일을 전부 읽는다.
- [ ] task create/list/get/enqueue proto-socket action handler를 구현한다. 검증: REST task API와 같은 의미의 요청/응답이 proto-socket 경로에서 동작한다.
- [ ] task status event를 notification fanout을 통해 proto-socket `task.status.changed` event로 broadcast한다.
- [ ] task channel, auth, error, heartbeat 동작을 contract tests로 고정한다.
- [ ] 허용된 비로컬 테스트 환경에서 중간/최종 검증 명령을 `-count=1`로 실행하고 실제 출력을 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

이 subtask directory는 `02+01_task_channels`이므로 같은 task group의 `01_core_endpoint`가 먼저 PASS되어야 한다. 구현 시작 전 `agent-task/m-proto-socket-infrastructure-communication-rail/01_core_endpoint/complete.log` 또는 archive된 완료 로그를 확인하고, predecessor가 만든 `services/core/internal/protosocket/*.go`와 tests를 전부 읽는다.

### [TASK-CHANNELS-1] task action dispatcher

#### 문제

REST task API는 `services/core/internal/http/handlers.go:62-80`, `128-168`에서 `CreateTask`, `ListTasks`, `GetTask`, `EnqueueTask`를 제공한다. Workflow service는 동일 의미의 메서드를 `services/core/internal/workflow/service.go:37-75`, `130-172`에 이미 갖고 있지만 proto-socket action mapping이 없다. `../proto-socket/go/communicator.go:242-252`는 한 protobuf type당 request listener를 하나만 허용하므로, `structpb.Struct` envelope는 action dispatcher 하나로 분기해야 한다.

#### 해결 방법

`services/core/internal/protosocket/tasks.go`를 추가한다. `TaskService` interface는 workflow service의 필요한 메서드만 받는다. `TaskChannels.Register(dispatcher *Dispatcher)` 또는 predecessor가 제공한 동등한 registry surface에 action handlers를 등록한다.

After:

```go
package protosocket

import (
	"context"
	"encoding/json"
	"errors"

	"github.com/jackc/pgx/v5"
	"github.com/nomadcode/nomadcode-core/internal/storage"
	"github.com/nomadcode/nomadcode-core/internal/workflow"
)

type TaskService interface {
	CreateTask(context.Context, workflow.CreateTaskInput) (storage.Task, error)
	ListTasks(context.Context, int32) ([]storage.Task, error)
	GetTask(context.Context, string) (storage.Task, error)
	EnqueueTask(context.Context, string) (storage.Task, error)
}

type TaskChannels struct {
	tasks TaskService
}

func NewTaskChannels(tasks TaskService) *TaskChannels
func (c *TaskChannels) Register(dispatcher *Dispatcher)
```

Action payload rules:

- `task.create`: payload is REST `POST /api/tasks` JSON shape. Response payload mirrors REST create response plus `task`.
- `task.list`: payload `{ "limit": 20 }`. Response payload `{ "tasks": [...] }`.
- `task.get`: payload `{ "id": "task-id" }`. Response payload `{ "task": { ... } }`.
- `task.enqueue`: payload `{ "id": "task-id" }`. Response payload `{ "id": "...", "status": "queued", "task": { ... } }`.

Error envelope mapping:

```go
switch {
case errors.Is(err, workflow.ErrInvalidTaskInput):
	return ErrorResponse(req, "task.invalid_input", err.Error(), false)
case errors.Is(err, workflow.ErrTaskCannotBeEnqueued):
	return ErrorResponse(req, "task.conflict", err.Error(), false)
case errors.Is(err, pgx.ErrNoRows):
	return ErrorResponse(req, "task.not_found", "task not found", false)
default:
	return ErrorResponse(req, "internal.error", "internal server error", true)
}
```

Use JSON marshal/unmarshal at the envelope boundary instead of ad hoc map indexing for complex task structs:

```go
func payloadAs[T any](payload map[string]any) (T, error) {
	var out T
	b, err := json.Marshal(payload)
	if err != nil {
		return out, err
	}
	return out, json.Unmarshal(b, &out)
}
```

#### 수정 파일 및 체크리스트

- [ ] `services/core/internal/protosocket/tasks.go`: task service interface, channel registration, action handlers, payload decode, task response encoding, error mapping을 추가한다.
- [ ] `services/core/internal/protosocket/tasks_test.go`: fake `TaskService`로 success/error cases를 검증한다.
- [ ] `services/core/cmd/server/main.go`: `workflowService` 생성 뒤 task channels를 proto-socket dispatcher에 등록한다.
- [ ] `packages/contracts/notes/flutter-core-api-candidates.md`: task action payload/response 후보가 구현과 불일치하면 갱신한다.

#### 테스트 작성

- 작성: `services/core/internal/protosocket/tasks_test.go`
  - `TestTaskCreateActionCallsWorkflowAndReturnsResponse`
  - `TestTaskListActionUsesDefaultAndBoundedLimit`
  - `TestTaskGetActionMapsNotFoundError`
  - `TestTaskEnqueueActionMapsConflictError`
  - `TestTaskActionRejectsInvalidPayload`

#### 중간 검증

로컬 테스트 실행은 금지다. 허용된 비로컬 테스트 환경에서 실행한다.

```bash
cd services/core && go test -count=1 ./internal/protosocket
```

예상 결과: task action success/error tests PASS.

### [TASK-CHANNELS-2] task status event broadcast

#### 문제

Scheduler는 `services/core/internal/scheduler/jobs.go:311-319`에서 `w.Notifications.NotifyTaskEvent(ctx, event)`를 호출한다. Notification service는 `services/core/internal/notification/service.go:32-51`에서 log를 남기고 completed event만 Mattermost로 보낸 뒤 non-completed event는 반환한다. proto-socket client가 task status 변화를 받는 fanout path가 없다.

#### 해결 방법

`notification` package에 sink interface를 추가하고 `NewService`를 variadic으로 확장해 기존 call site를 깨지 않는다.

Before:

```go
// services/core/internal/notification/service.go:10
type Service struct {
	mattermost *mattermost.Client
	logger     *slog.Logger
}

func NewService(mattermostClient *mattermost.Client, logger *slog.Logger) *Service {
	return &Service{
		mattermost: mattermostClient,
		logger:     logger,
	}
}
```

After:

```go
type TaskEventSink interface {
	HandleTaskEvent(context.Context, TaskEvent) error
}

type Service struct {
	mattermost *mattermost.Client
	logger     *slog.Logger
	sinks      []TaskEventSink
}

func NewService(mattermostClient *mattermost.Client, logger *slog.Logger, sinks ...TaskEventSink) *Service {
	return &Service{
		mattermost: mattermostClient,
		logger:     logger,
		sinks:      append([]TaskEventSink{}, sinks...),
	}
}
```

`NotifyTaskEvent`는 모든 event를 sink로 보내고, completed Mattermost behavior는 유지한다.

```go
var errs []error
for _, sink := range s.sinks {
	if err := sink.HandleTaskEvent(ctx, input); err != nil {
		errs = append(errs, err)
	}
}
if input.Type == TaskEventCompleted && s.mattermost != nil {
	errs = append(errs, s.mattermost.SendTaskNotification(ctx, mattermost.TaskNotificationInput{...}))
}
return errors.Join(errs...)
```

`protosocket.Server` 또는 작은 adapter가 `HandleTaskEvent`를 구현해 `task.status.changed` event envelope를 broadcast한다.

```go
func (s *Server) HandleTaskEvent(ctx context.Context, event notification.TaskEvent) error {
	return s.BroadcastEnvelope(ctx, Envelope{
		ProtocolVersion: ProtocolVersion,
		Type:            "event",
		Channel:         "task",
		Action:          "task.status.changed",
		Payload: map[string]any{
			"id":      event.TaskID,
			"type":    string(event.Type),
			"status":  event.Status,
			"title":   event.Title,
			"message": event.Message,
		},
	})
}
```

`cmd/server/main.go` order는 proto-socket server를 notification service보다 먼저 만들고, workflow service 생성 뒤 task channels를 등록한다.

#### 수정 파일 및 체크리스트

- [ ] `services/core/internal/notification/model.go` 또는 `service.go`: `TaskEventSink` interface를 추가한다.
- [ ] `services/core/internal/notification/service.go`: variadic sink, fanout, `errors.Join`, completed Mattermost 유지 로직을 추가한다.
- [ ] `services/core/internal/notification/service_test.go`: sink fanout와 sink error 반환 테스트를 추가한다.
- [ ] `services/core/internal/protosocket/tasks.go` 또는 `events.go`: `HandleTaskEvent` 구현과 `task.status.changed` envelope mapping을 추가한다.
- [ ] `services/core/cmd/server/main.go`: proto-socket server를 notification sink로 wire한다.

#### 테스트 작성

- 작성/수정: `services/core/internal/notification/service_test.go`
  - `TestNotifyTaskEventFansOutToSinksForRunningAndFailed`
  - `TestNotifyTaskEventReturnsSinkErrors`
  - 기존 completed/non-completed behavior가 유지되는지 확인한다.
- 작성: `services/core/internal/protosocket/tasks_test.go`
  - `TestHandleTaskEventBroadcastsStatusChangedEnvelope`: fake broadcaster/client로 action/payload 검증.

#### 중간 검증

```bash
cd services/core && go test -count=1 ./internal/notification ./internal/protosocket ./internal/scheduler
```

예상 결과: notification fanout, proto-socket event mapping, scheduler notification tests PASS.

### [TASK-CHANNELS-3] contract tests and route-level auth regression

#### 문제

Milestone의 `[core-contract-tests]`는 core proto-socket channel, auth, error, heartbeat 동작을 테스트로 고정해야 한다. `01_core_endpoint`는 endpoint와 unsupported action을 고정하지만, task channel 구현 뒤에는 실제 `task.*` action과 Basic Auth-protected websocket route가 함께 검증되어야 한다.

#### 해결 방법

`httptest.Server`에 실제 router와 proto-socket server를 올리고 `nhooyr.io/websocket` + `toki.NewWsClient`로 request/response를 검증한다. 인증 테스트는 Basic Auth가 없을 때 websocket dial이 실패하고, credentials가 있으면 `task.list`가 성공하는 route-level test로 둔다.

After test outline:

```go
func TestRouterProtoSocketTaskListRequiresAuth(t *testing.T) {
	// unauthenticated websocket dial to /proto-socket fails
	// authenticated dial succeeds and task.list returns response envelope
}

func TestProtoSocketTaskActionsMatchRestSemantics(t *testing.T) {
	// fake TaskService or test DB fixture로 create/list/get/enqueue response payload shape 검증
}
```

Heartbeat는 proto-socket library behavior와 Core config wiring을 대상으로 한다. Timer sleep 기반 long wait test는 만들지 않는다.

```go
func TestProtoSocketServerUsesConfiguredHeartbeat(t *testing.T) {
	server := NewServer(Config{HeartbeatIntervalSec: 1, HeartbeatWaitSec: 1}, nil)
	// connect and close without panic/leak; no sleep assertion
}
```

#### 수정 파일 및 체크리스트

- [ ] `services/core/internal/protosocket/tasks_test.go`: route-level auth와 task action integration-style tests를 추가한다.
- [ ] `services/core/internal/http/middleware_test.go`: predecessor auth test와 겹치지 않는 경우 proto-socket route protected assertion을 유지/확장한다.
- [ ] `packages/contracts/notes/flutter-core-api-candidates.md`: 구현된 action payload/response/error code 표를 추가한다.

#### 테스트 작성

- 작성: `TestRouterProtoSocketTaskListRequiresAuth`
- 작성: `TestProtoSocketTaskActionsMatchRestSemantics`
- 작성: `TestProtoSocketTaskErrorEnvelopeCodes`
- 작성: `TestProtoSocketServerUsesConfiguredHeartbeat`

#### 중간 검증

```bash
cd services/core && go test -count=1 ./internal/protosocket ./internal/http
```

예상 결과: task contract, auth, error, heartbeat route tests PASS.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `services/core/internal/protosocket/tasks.go` | TASK-CHANNELS-1, TASK-CHANNELS-2 |
| `services/core/internal/protosocket/events.go` | TASK-CHANNELS-2 |
| `services/core/internal/protosocket/tasks_test.go` | TASK-CHANNELS-1, TASK-CHANNELS-2, TASK-CHANNELS-3 |
| `services/core/internal/notification/model.go` | TASK-CHANNELS-2 |
| `services/core/internal/notification/service.go` | TASK-CHANNELS-2 |
| `services/core/internal/notification/service_test.go` | TASK-CHANNELS-2 |
| `services/core/internal/http/middleware_test.go` | TASK-CHANNELS-3 |
| `services/core/cmd/server/main.go` | TASK-CHANNELS-1, TASK-CHANNELS-2 |
| `packages/contracts/notes/flutter-core-api-candidates.md` | TASK-CHANNELS-1, TASK-CHANNELS-3 |

## 최종 검증

로컬 테스트 실행은 `agent-test/local/rules.md`에 따라 금지다. 구현 에이전트는 허용된 비로컬 테스트 환경에서 아래 명령을 그대로 실행한다. Go test cache output은 허용하지 않으며 `-count=1`을 유지한다.

```bash
test -f agent-task/m-proto-socket-infrastructure-communication-rail/01_core_endpoint/complete.log || find agent-task/archive -path '*/m-proto-socket-infrastructure-communication-rail/01_core_endpoint/complete.log' -print -quit
```

예상 결과: predecessor completion log path가 출력되거나 active path에 파일이 존재한다.

```bash
cd services/core && go test -count=1 ./internal/protosocket ./internal/notification ./internal/http ./internal/scheduler
```

예상 결과: task actions, event broadcast, auth/error/heartbeat, scheduler notification regression PASS.

```bash
cd services/core && go test -count=1 ./...
```

예상 결과: Core 전체 package PASS.

```bash
git diff --check
```

예상 결과: whitespace error 없음.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
