iop/apps/edge/internal/service/workspace_wire.go
toki d7a150c7fe feat(agent): 단일 요청 실행 경로를 완성한다
Claude의 단일 Anthropic 요청 안에서 IOP가 Plan, Work, Review와 workspace 도구 실행을 끝내고 실제 dev smoke로 계약을 검증할 수 있어야 한다.\n\n완료 task evidence와 마일스톤 검토 상태도 같은 변경에 고정한다.
2026-08-08 23:35:13 +09:00

362 lines
15 KiB
Go

package service
import (
"context"
"errors"
"time"
toki "git.toki-labs.com/toki/proto-socket/go"
edgenode "iop/apps/edge/internal/node"
"iop/packages/go/config"
"iop/packages/go/workspaceprotocol"
iop "iop/proto/gen/iop"
)
var (
errWorkspaceWireUnavailable = errors.New("workspace wire: admitted node is unavailable")
errWorkspaceWireStale = errors.New("workspace wire: admitted node connection changed")
errWorkspaceWireTransport = errors.New("workspace wire: request failed")
errWorkspaceWireReference = errors.New("workspace wire: workspace reference is not admitted")
errWorkspaceWireResponse = errors.New("workspace wire: node response was not accepted")
errWorkspaceWireArtifact = errors.New("workspace wire: artifact request was not accepted")
)
// workspaceOpen sends only to the Node and connection generation frozen by
// workspace admission. It never resolves a replacement Node after reconnect, and
// it rejects any request whose workspace reference is not the exact admitted
// reference before dispatching to the Node.
func (s *Service) workspaceOpen(ctx context.Context, binding *SingleRequestWorkspaceBinding, req *iop.WorkspaceOpenRequest) (*iop.WorkspaceOpenResponse, error) {
if req == nil || req.GetRequestId() == "" {
return nil, errWorkspaceWireUnavailable
}
if binding == nil {
return nil, errWorkspaceWireUnavailable
}
if req.GetWorkspaceRef() == "" || req.GetWorkspaceRef() != binding.Ref {
return nil, errWorkspaceWireReference
}
outbound, err := workspaceOpenRequestFromBinding(req, binding)
if err != nil {
return nil, errWorkspaceWireUnavailable
}
wait := workspaceWireTimeout(ctx, binding, outbound.GetTimeoutMs())
var response *iop.WorkspaceOpenResponse
err = s.withWorkspaceBinding(binding, func(entry *edgenode.NodeEntry) error {
var err error
response, err = toki.SendRequestTyped[*iop.WorkspaceOpenRequest, *iop.WorkspaceOpenResponse](&entry.Client.Communicator, outbound, wait)
return err
})
if err != nil {
return nil, workspaceWireError(err)
}
return validateWorkspaceOpenResponse(outbound, response)
}
func workspaceOpenRequestFromBinding(req *iop.WorkspaceOpenRequest, binding *SingleRequestWorkspaceBinding) (*iop.WorkspaceOpenRequest, error) {
frozen, err := validateAndCloneWorkspaceBinding(binding)
if err != nil {
return nil, err
}
operations := make([]iop.WorkspaceOperation, 0, len(frozen.OperationIDs))
var readEnabled, listEnabled, writeEnabled, commandEnabled bool
for _, operationID := range frozen.OperationIDs {
switch config.WorkspaceOperation(operationID) {
case config.WorkspaceOpRead:
operations = append(operations, iop.WorkspaceOperation_WORKSPACE_OPERATION_READ)
readEnabled = true
case config.WorkspaceOpList:
operations = append(operations, iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST)
listEnabled = true
case config.WorkspaceOpWrite:
operations = append(operations, iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE)
writeEnabled = true
case config.WorkspaceOpDelete:
operations = append(operations, iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE)
case config.WorkspaceOpCommand:
operations = append(operations, iop.WorkspaceOperation_WORKSPACE_OPERATION_COMMAND)
commandEnabled = true
default:
return nil, errSingleRequestWorkspaceMalformed
}
}
outbound := &iop.WorkspaceOpenRequest{
RequestId: req.GetRequestId(), WorkspaceRef: frozen.Ref, TimeoutMs: req.GetTimeoutMs(),
Operations: operations, CommandIds: append([]string(nil), frozen.CommandIDs...),
}
if readEnabled {
outbound.MaxReadBytes = int64(frozen.Limits.MaxReadBytes)
}
if writeEnabled {
outbound.MaxWriteBytes = int64(frozen.Limits.MaxWriteBytes)
}
if listEnabled || commandEnabled {
outbound.MaxOutputBytes = int64(frozen.Limits.MaxOutputBytes)
}
if commandEnabled {
outbound.MaxCommandTimeoutMs = int64(frozen.Limits.MaxCommandTimeoutMS)
}
return outbound, nil
}
// workspaceTool uses a bounded waiter and sends one typed cancel for an
// in-flight tool call when its context is cancelled. The immutable request,
// stage, and tool-call identities are copied to that cancellation request.
//
// The admitted Node communicator is captured once up front. When the caller
// context wins the race against the tool response, cancellation is a single
// fire-and-forget typed send to that captured communicator: it never acquires
// the registry dispatch-owner mutex the in-flight tool request is holding, never
// re-selects a Node, and does not wait for a cancel response.
func (s *Service) workspaceTool(ctx context.Context, binding *SingleRequestWorkspaceBinding, req *iop.WorkspaceToolRequest) (*iop.WorkspaceToolResponse, error) {
if req == nil || req.GetRequestId() == "" || req.GetStageId() == "" || req.GetToolCallId() == "" {
return nil, errWorkspaceWireUnavailable
}
client, err := s.captureWorkspaceClient(binding)
if err != nil {
return nil, err
}
type result struct {
response *iop.WorkspaceToolResponse
err error
}
resultCh := make(chan result, 1)
wait := workspaceWireTimeout(ctx, binding, req.GetTimeoutMs())
go func() {
var response *iop.WorkspaceToolResponse
err := s.withWorkspaceBinding(binding, func(entry *edgenode.NodeEntry) error {
var requestErr error
response, requestErr = toki.SendRequestTyped[*iop.WorkspaceToolRequest, *iop.WorkspaceToolResponse](&entry.Client.Communicator, req, wait)
return requestErr
})
resultCh <- result{response: response, err: err}
}()
select {
case result := <-resultCh:
if result.err != nil {
return nil, workspaceWireError(result.err)
}
return validateWorkspaceToolResponse(req, result.response)
case <-ctx.Done():
s.sendWorkspaceCancelToClient(client, binding, req)
return nil, context.Cause(ctx)
}
}
// workspaceArtifact dispatches the coordinator-only PLAN/REVIEW artifact
// family to the frozen Node generation. Both request and response payloads are
// bounded by the admitted output limit before they can cross their respective
// trust boundaries.
func (s *Service) workspaceArtifact(ctx context.Context, binding *SingleRequestWorkspaceBinding, req *iop.WorkspaceArtifactRequest, maxBytes int) (*iop.WorkspaceArtifactResponse, error) {
if req == nil || req.GetRequestId() == "" || binding == nil || maxBytes < 1 || !validWorkspaceArtifactKind(req.GetKind()) || !validWorkspaceArtifactOperation(req.GetOperation()) {
return nil, errWorkspaceWireArtifact
}
limit := maxBytes
if (req.GetOperation() == iop.WorkspaceArtifactOperation_WORKSPACE_ARTIFACT_OPERATION_READ && len(req.GetContent()) != 0) || len(req.GetContent()) > limit {
return nil, errWorkspaceWireArtifact
}
outbound := &iop.WorkspaceArtifactRequest{
RequestId: req.GetRequestId(), Kind: req.GetKind(), Operation: req.GetOperation(),
Content: append([]byte(nil), req.GetContent()...),
}
wait := workspaceWireTimeout(ctx, binding, 0)
var response *iop.WorkspaceArtifactResponse
err := s.withWorkspaceBinding(binding, func(entry *edgenode.NodeEntry) error {
var requestErr error
response, requestErr = toki.SendRequestTyped[*iop.WorkspaceArtifactRequest, *iop.WorkspaceArtifactResponse](&entry.Client.Communicator, outbound, wait)
return requestErr
})
if err != nil {
return nil, workspaceWireError(err)
}
return validateWorkspaceArtifactResponse(outbound, response, limit)
}
// sendWorkspaceCancelToClient issues exactly one fire-and-forget typed cancel to
// the captured admitted communicator, copying the immutable request/stage/tool
// identities. It runs in its own goroutine because the caller has already
// returned on context cancellation; it never resolves a replacement Node, never
// takes the registry lock, and ignores the cancel response.
func (s *Service) sendWorkspaceCancelToClient(client *toki.TcpClient, binding *SingleRequestWorkspaceBinding, req *iop.WorkspaceToolRequest) {
if client == nil {
return
}
cancel := &iop.WorkspaceCancelRequest{RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId()}
wait := workspaceWireTimeout(context.Background(), binding, 0)
go func() {
_, _ = toki.SendRequestTyped[*iop.WorkspaceCancelRequest, *iop.WorkspaceCancelResponse](&client.Communicator, cancel, wait)
}()
}
// captureWorkspaceClient snapshots the admitted Node's communicator and confirms
// its exact connection generation before any tool dispatch. The captured client
// is the only Node a later cancellation may target.
func (s *Service) captureWorkspaceClient(binding *SingleRequestWorkspaceBinding) (*toki.TcpClient, error) {
if binding == nil || binding.NodeID == "" || binding.ConnectionGeneration == 0 || s == nil || s.registry == nil {
return nil, errWorkspaceWireUnavailable
}
entry, ok := s.registry.ReadyOwnerSnapshot(binding.NodeID)
if !ok || entry == nil || entry.Client == nil {
return nil, errWorkspaceWireUnavailable
}
if entry.ConnectionGeneration != binding.ConnectionGeneration {
return nil, errWorkspaceWireStale
}
return entry.Client, nil
}
func (s *Service) workspaceCancel(ctx context.Context, binding *SingleRequestWorkspaceBinding, req *iop.WorkspaceCancelRequest) (*iop.WorkspaceCancelResponse, error) {
if req == nil || req.GetRequestId() == "" || req.GetStageId() == "" || req.GetToolCallId() == "" {
return nil, errWorkspaceWireUnavailable
}
wait := workspaceWireTimeout(ctx, binding, 0)
var response *iop.WorkspaceCancelResponse
err := s.withWorkspaceBinding(binding, func(entry *edgenode.NodeEntry) error {
var err error
response, err = toki.SendRequestTyped[*iop.WorkspaceCancelRequest, *iop.WorkspaceCancelResponse](&entry.Client.Communicator, req, wait)
return err
})
if err != nil {
return nil, workspaceWireError(err)
}
return validateWorkspaceCancelResponse(req, response)
}
func (s *Service) workspaceCleanup(ctx context.Context, binding *SingleRequestWorkspaceBinding, req *iop.WorkspaceCleanupRequest) (*iop.WorkspaceCleanupResponse, error) {
if req == nil || req.GetRequestId() == "" {
return nil, errWorkspaceWireUnavailable
}
wait := workspaceWireTimeout(ctx, binding, 0)
var response *iop.WorkspaceCleanupResponse
err := s.withWorkspaceBinding(binding, func(entry *edgenode.NodeEntry) error {
var err error
response, err = toki.SendRequestTyped[*iop.WorkspaceCleanupRequest, *iop.WorkspaceCleanupResponse](&entry.Client.Communicator, req, wait)
return err
})
if err != nil {
return nil, workspaceWireError(err)
}
return validateWorkspaceCleanupResponse(req, response)
}
// The response validators enforce the immutable coordinator identity echoes and
// the exact canonical status, error-code, and generic message triples for each
// operation using workspaceprotocol authority. Mismatched identities,
// non-canonical status/code pairs, raw text leakage, or nil responses are
// translated to errWorkspaceWireResponse. They never return, log, or
// interpolate raw Node-supplied text.
func validateWorkspaceOpenResponse(req *iop.WorkspaceOpenRequest, resp *iop.WorkspaceOpenResponse) (*iop.WorkspaceOpenResponse, error) {
if resp == nil || resp.GetRequestId() != req.GetRequestId() || resp.GetWorkspaceRef() != req.GetWorkspaceRef() {
return nil, errWorkspaceWireResponse
}
expectedErr, ok := workspaceprotocol.OpenTerminal(resp.GetStatus(), resp.GetErrorCode())
if !ok || resp.GetError() != expectedErr {
return nil, errWorkspaceWireResponse
}
return resp, nil
}
func validateWorkspaceToolResponse(req *iop.WorkspaceToolRequest, resp *iop.WorkspaceToolResponse) (*iop.WorkspaceToolResponse, error) {
if resp == nil || resp.GetRequestId() != req.GetRequestId() || resp.GetStageId() != req.GetStageId() || resp.GetToolCallId() != req.GetToolCallId() {
return nil, errWorkspaceWireResponse
}
expectedErr, ok := workspaceprotocol.ToolTerminal(resp.GetStatus(), resp.GetErrorCode())
if !ok || resp.GetError() != expectedErr {
return nil, errWorkspaceWireResponse
}
return resp, nil
}
func validateWorkspaceArtifactResponse(req *iop.WorkspaceArtifactRequest, resp *iop.WorkspaceArtifactResponse, limit int) (*iop.WorkspaceArtifactResponse, error) {
if resp == nil || resp.GetRequestId() != req.GetRequestId() || resp.GetKind() != req.GetKind() || resp.GetOperation() != req.GetOperation() {
return nil, errWorkspaceWireResponse
}
expectedErr, ok := workspaceprotocol.ArtifactTerminal(resp.GetStatus(), resp.GetErrorCode())
if !ok || resp.GetError() != expectedErr || len(resp.GetContent()) > limit {
return nil, errWorkspaceWireResponse
}
if (resp.GetStatus() != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS && len(resp.GetContent()) != 0) ||
(resp.GetOperation() == iop.WorkspaceArtifactOperation_WORKSPACE_ARTIFACT_OPERATION_WRITE && len(resp.GetContent()) != 0) {
return nil, errWorkspaceWireResponse
}
return resp, nil
}
func validWorkspaceArtifactKind(kind iop.WorkspaceArtifactKind) bool {
return kind == iop.WorkspaceArtifactKind_WORKSPACE_ARTIFACT_KIND_PLAN || kind == iop.WorkspaceArtifactKind_WORKSPACE_ARTIFACT_KIND_REVIEW
}
func validWorkspaceArtifactOperation(operation iop.WorkspaceArtifactOperation) bool {
return operation == iop.WorkspaceArtifactOperation_WORKSPACE_ARTIFACT_OPERATION_READ || operation == iop.WorkspaceArtifactOperation_WORKSPACE_ARTIFACT_OPERATION_WRITE
}
func validateWorkspaceCancelResponse(req *iop.WorkspaceCancelRequest, resp *iop.WorkspaceCancelResponse) (*iop.WorkspaceCancelResponse, error) {
if resp == nil || resp.GetRequestId() != req.GetRequestId() || resp.GetStageId() != req.GetStageId() || resp.GetToolCallId() != req.GetToolCallId() {
return nil, errWorkspaceWireResponse
}
expectedErr, ok := workspaceprotocol.CancelTerminal(resp.GetStatus(), resp.GetErrorCode())
if !ok || resp.GetError() != expectedErr {
return nil, errWorkspaceWireResponse
}
return resp, nil
}
func validateWorkspaceCleanupResponse(req *iop.WorkspaceCleanupRequest, resp *iop.WorkspaceCleanupResponse) (*iop.WorkspaceCleanupResponse, error) {
if resp == nil || resp.GetRequestId() != req.GetRequestId() {
return nil, errWorkspaceWireResponse
}
expectedErr, ok := workspaceprotocol.CleanupTerminal(resp.GetStatus(), resp.GetErrorCode())
if !ok || resp.GetError() != expectedErr {
return nil, errWorkspaceWireResponse
}
return resp, nil
}
func (s *Service) withWorkspaceBinding(binding *SingleRequestWorkspaceBinding, send func(*edgenode.NodeEntry) error) error {
if binding == nil || binding.NodeID == "" || binding.ConnectionGeneration == 0 || s == nil || s.registry == nil {
return errWorkspaceWireUnavailable
}
entry, ok := s.registry.ReadyOwnerSnapshot(binding.NodeID)
if !ok || entry == nil || entry.Client == nil {
return errWorkspaceWireUnavailable
}
if entry.ConnectionGeneration != binding.ConnectionGeneration {
return errWorkspaceWireStale
}
if err := s.registry.WithCurrentDispatchOwner(binding.NodeID, entry.Client, binding.ConnectionGeneration, func() error {
return send(entry)
}); err != nil {
return errWorkspaceWireStale
}
return nil
}
func workspaceWireTimeout(ctx context.Context, binding *SingleRequestWorkspaceBinding, requestMS int64) time.Duration {
wait := 30 * time.Second
if binding != nil && binding.Limits.MaxCommandTimeoutMS > 0 {
wait = time.Duration(binding.Limits.MaxCommandTimeoutMS) * time.Millisecond
}
if requestMS > 0 {
requestWait := time.Duration(requestMS) * time.Millisecond
if requestWait < wait {
wait = requestWait
}
}
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining < wait {
wait = remaining
}
}
if wait <= 0 {
return time.Millisecond
}
return wait
}
func workspaceWireError(err error) error {
if errors.Is(err, errWorkspaceWireUnavailable) || errors.Is(err, errWorkspaceWireStale) {
return err
}
return errWorkspaceWireTransport
}