Claude의 단일 Anthropic 요청 안에서 IOP가 Plan, Work, Review와 workspace 도구 실행을 끝내고 실제 dev smoke로 계약을 검증할 수 있어야 한다.\n\n완료 task evidence와 마일스톤 검토 상태도 같은 변경에 고정한다.
362 lines
15 KiB
Go
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
|
|
}
|