Files
tutti/services/tuttid/api/daemon_workspace_workflows.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

437 lines
20 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"strings"
"time"
"github.com/google/uuid"
tuttigenerated "github.com/tutti-os/tutti/services/tuttid/api/generated"
"github.com/tutti-os/tutti/services/tuttid/apierrors"
workflowbiz "github.com/tutti-os/tutti/services/tuttid/biz/workspaceworkflow"
workspacedata "github.com/tutti-os/tutti/services/tuttid/data/workspace"
tuttimodeplanservice "github.com/tutti-os/tutti/services/tuttid/service/tuttimodeplan"
tuttitypes "github.com/tutti-os/tutti/services/tuttid/types"
)
type TuttiModePlanService interface {
GetView(context.Context, tuttimodeplanservice.GetInput) (tuttimodeplanservice.SnapshotView, error)
ListBySourceSession(context.Context, string, string) ([]tuttimodeplanservice.SnapshotView, error)
ListPendingBySourceSession(context.Context, string, string) ([]tuttimodeplanservice.SnapshotView, error)
Decide(context.Context, tuttimodeplanservice.DecideInput) (tuttimodeplanservice.DecisionResult, error)
}
func registerWorkspaceWorkflowRoutes(
mux *http.ServeMux,
wrapper *tuttigenerated.ServerInterfaceWrapper,
) {
mux.HandleFunc("/v1/workspaces/{workspaceID}/workflows", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
tuttitypes.WriteMethodNotAllowed(w)
return
}
wrapper.ListWorkspaceWorkflows(w, r)
})
mux.HandleFunc("/v1/workspaces/{workspaceID}/workflows/{workflowID}", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
tuttitypes.WriteMethodNotAllowed(w)
return
}
wrapper.GetWorkspaceWorkflow(w, r)
})
mux.HandleFunc("/v1/workspaces/{workspaceID}/workflows/{workflowID}/checkpoints/{checkpointID}/decision", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
tuttitypes.WriteMethodNotAllowed(w)
return
}
wrapper.DecideWorkspaceWorkflowCheckpoint(w, r)
})
}
func (api DaemonAPI) ListWorkspaceWorkflows(ctx context.Context, request tuttigenerated.ListWorkspaceWorkflowsRequestObject) (tuttigenerated.ListWorkspaceWorkflowsResponseObject, error) {
if status := request.Params.CheckpointStatus; status != nil && !status.Valid() {
return tuttigenerated.ListWorkspaceWorkflows400JSONResponse{
InvalidRequestErrorJSONResponse: invalidRequestError(
apierrors.MalformedRequest(apierrors.WithDeveloperMessage("checkpointStatus must be pending")),
),
}, nil
}
if api.TuttiModePlanService == nil {
return tuttigenerated.ListWorkspaceWorkflows503JSONResponse{ServiceUnavailableErrorJSONResponse: workflowServiceUnavailable()}, nil
}
var views []tuttimodeplanservice.SnapshotView
var err error
if request.Params.CheckpointStatus == nil {
views, err = api.TuttiModePlanService.ListBySourceSession(ctx, request.WorkspaceID, request.Params.SourceSessionId)
} else {
views, err = api.TuttiModePlanService.ListPendingBySourceSession(ctx, request.WorkspaceID, request.Params.SourceSessionId)
}
if err != nil {
return listWorkspaceWorkflowsError(err), nil
}
workflows := make([]tuttigenerated.WorkspaceWorkflowSnapshot, 0, len(views))
for _, view := range views {
projected, projectionErr := generatedWorkspaceWorkflowSnapshot(view)
if projectionErr != nil {
return listWorkspaceWorkflowsError(projectionErr), nil
}
workflows = append(workflows, projected)
}
return tuttigenerated.ListWorkspaceWorkflows200JSONResponse{Workflows: workflows}, nil
}
func (api DaemonAPI) GetWorkspaceWorkflow(ctx context.Context, request tuttigenerated.GetWorkspaceWorkflowRequestObject) (tuttigenerated.GetWorkspaceWorkflowResponseObject, error) {
if api.TuttiModePlanService == nil {
return tuttigenerated.GetWorkspaceWorkflow503JSONResponse{ServiceUnavailableErrorJSONResponse: workflowServiceUnavailable()}, nil
}
view, err := api.TuttiModePlanService.GetView(ctx, tuttimodeplanservice.GetInput{
WorkspaceID: request.WorkspaceID,
WorkflowID: request.WorkflowID.String(),
})
if err != nil {
return getWorkspaceWorkflowError(err), nil
}
projected, err := generatedWorkspaceWorkflowSnapshot(view)
if err != nil {
return getWorkspaceWorkflowError(err), nil
}
return tuttigenerated.GetWorkspaceWorkflow200JSONResponse(projected), nil
}
func (api DaemonAPI) DecideWorkspaceWorkflowCheckpoint(ctx context.Context, request tuttigenerated.DecideWorkspaceWorkflowCheckpointRequestObject) (tuttigenerated.DecideWorkspaceWorkflowCheckpointResponseObject, error) {
if request.Body == nil {
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint400JSONResponse{InvalidRequestErrorJSONResponse: invalidRequestError(apierrors.EmptyBody())}, nil
}
if !request.Body.Decision.Valid() {
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint400JSONResponse{
InvalidRequestErrorJSONResponse: invalidRequestError(
apierrors.MalformedRequest(apierrors.WithDeveloperMessage("decision must be accepted, rejected, or canceled")),
),
}, nil
}
if api.TuttiModePlanService == nil {
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint503JSONResponse{ServiceUnavailableErrorJSONResponse: workflowServiceUnavailable()}, nil
}
reason := ""
if request.Body.Reason != nil {
reason = *request.Body.Reason
}
_, err := api.TuttiModePlanService.Decide(ctx, tuttimodeplanservice.DecideInput{
WorkspaceID: request.WorkspaceID,
WorkflowID: request.WorkflowID.String(),
CheckpointID: request.CheckpointID.String(),
Decision: workflowbiz.CheckpointStatus(request.Body.Decision),
DecidedBy: request.Body.DecidedBy,
DecisionReason: reason,
TaskAssignments: workflowTaskAssignmentsFromGenerated(request.Body.TaskAssignments),
})
if err != nil {
return decideWorkspaceWorkflowError(err), nil
}
view, err := api.TuttiModePlanService.GetView(ctx, tuttimodeplanservice.GetInput{
WorkspaceID: request.WorkspaceID,
WorkflowID: request.WorkflowID.String(),
})
if err != nil {
return decideWorkspaceWorkflowError(err), nil
}
projected, err := generatedWorkspaceWorkflowSnapshot(view)
if err != nil {
return decideWorkspaceWorkflowError(err), nil
}
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint200JSONResponse(projected), nil
}
func generatedWorkspaceWorkflowSnapshot(view tuttimodeplanservice.SnapshotView) (tuttigenerated.WorkspaceWorkflowSnapshot, error) {
workflowID, err := uuid.Parse(view.Workflow.ID)
if err != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, fmt.Errorf("invalid persisted workflow id %q: %w", view.Workflow.ID, err)
}
currentRevisionID, err := uuid.Parse(view.Workflow.CurrentRevisionID)
if err != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, fmt.Errorf("invalid persisted current revision id %q: %w", view.Workflow.CurrentRevisionID, err)
}
result := tuttigenerated.WorkspaceWorkflowSnapshot{
Workflow: tuttigenerated.WorkspaceWorkflow{
Id: workflowID,
WorkspaceId: view.Workflow.WorkspaceID,
Type: tuttigenerated.WorkspaceWorkflowType(view.Workflow.Type),
Owner: tuttigenerated.WorkspaceWorkflowOwner(view.Workflow.Owner),
TriggerKind: tuttigenerated.WorkspaceWorkflowTriggerKind(view.Workflow.TriggerKind),
SourceSessionId: view.Workflow.SourceSessionID,
SourceTurnId: stringPointerIfNotBlank(view.Workflow.SourceTurnID),
SourceToolCallId: stringPointerIfNotBlank(view.Workflow.SourceToolCallID),
Status: tuttigenerated.WorkspaceWorkflowStatus(view.Workflow.Status),
CurrentRevisionId: currentRevisionID,
CreatedAtUnixMs: view.Workflow.CreatedAt.UnixMilli(),
UpdatedAtUnixMs: view.Workflow.UpdatedAt.UnixMilli(),
},
Revisions: make([]tuttigenerated.WorkspaceWorkflowPlanRevision, 0, len(view.Revisions)),
Checkpoints: make([]tuttigenerated.WorkspaceWorkflowCheckpoint, 0, len(view.Checkpoints)),
TurnLinks: make([]tuttigenerated.WorkspaceWorkflowTurnLink, 0, len(view.TurnLinks)),
Operations: make([]tuttigenerated.WorkspaceWorkflowOperation, 0, len(view.Operations)),
ActionableItems: make([]tuttigenerated.WorkspaceWorkflowActionableItem, 0, len(view.ActionableItems)),
}
for _, revision := range view.Revisions {
if err := validateFiniteWorkflowBudget(revision.Document.Budget); err != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, err
}
id, parseErr := uuid.Parse(revision.Revision.ID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
revisionWorkflowID, parseErr := uuid.Parse(revision.Revision.WorkflowID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
result.Revisions = append(result.Revisions, tuttigenerated.WorkspaceWorkflowPlanRevision{
Id: id,
WorkflowId: revisionWorkflowID,
Sequence: revision.Revision.Sequence,
SchemaVersion: tuttigenerated.WorkspaceWorkflowPlanRevisionSchemaVersion(revision.Revision.SchemaVersion),
DocumentPath: revision.Revision.DocumentPath,
Sha256: revision.Revision.SHA256,
ProducedByTurnId: stringPointerIfNotBlank(revision.Revision.ProducedByTurnID),
CreatedAtUnixMs: revision.Revision.CreatedAt.UnixMilli(),
Document: generatedTuttiModePlanDocument(revision.Document),
})
}
for _, checkpoint := range view.Checkpoints {
id, parseErr := uuid.Parse(checkpoint.ID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
checkpointWorkflowID, parseErr := uuid.Parse(checkpoint.WorkflowID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
revisionID, parseErr := uuid.Parse(checkpoint.RevisionID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
result.Checkpoints = append(result.Checkpoints, tuttigenerated.WorkspaceWorkflowCheckpoint{
Id: id,
WorkflowId: checkpointWorkflowID,
Kind: tuttigenerated.WorkspaceWorkflowCheckpointKind(checkpoint.Kind),
RevisionId: revisionID,
Status: tuttigenerated.WorkspaceWorkflowCheckpointStatus(checkpoint.Status),
DecidedBy: stringPointerIfNotBlank(checkpoint.DecidedBy),
DecisionReason: stringPointerIfNotBlank(checkpoint.DecisionReason),
CreatedAtUnixMs: checkpoint.CreatedAt.UnixMilli(),
UpdatedAtUnixMs: checkpoint.UpdatedAt.UnixMilli(),
DecidedAtUnixMs: unixMilliPointer(checkpoint.DecidedAt),
})
}
for _, link := range view.TurnLinks {
linkWorkflowID, parseErr := uuid.Parse(link.WorkflowID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
result.TurnLinks = append(result.TurnLinks, tuttigenerated.WorkspaceWorkflowTurnLink{
WorkflowId: linkWorkflowID,
TurnId: link.TurnID,
Relation: tuttigenerated.WorkspaceWorkflowTurnLinkRelation(link.Relation),
CreatedAtUnixMs: link.CreatedAt.UnixMilli(),
})
}
for _, operation := range view.Operations {
id, parseErr := uuid.Parse(operation.ID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
operationWorkflowID, parseErr := uuid.Parse(operation.WorkflowID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
var revisionID *uuid.UUID
if strings.TrimSpace(operation.RevisionID) != "" {
parsed, revisionErr := uuid.Parse(operation.RevisionID)
if revisionErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, revisionErr
}
revisionID = &parsed
}
result.Operations = append(result.Operations, tuttigenerated.WorkspaceWorkflowOperation{
Id: id,
WorkflowId: operationWorkflowID,
Kind: tuttigenerated.WorkspaceWorkflowOperationKind(operation.Kind),
Status: tuttigenerated.WorkspaceWorkflowOperationStatus(operation.Status),
RevisionId: revisionID,
IssueId: stringPointerIfNotBlank(operation.IssueID),
ErrorCode: stringPointerIfNotBlank(operation.ErrorCode),
ErrorMessage: stringPointerIfNotBlank(operation.ErrorMessage),
CreatedAtUnixMs: operation.CreatedAt.UnixMilli(),
UpdatedAtUnixMs: operation.UpdatedAt.UnixMilli(),
StartedAtUnixMs: unixMilliPointer(operation.StartedAt),
CompletedAtUnixMs: unixMilliPointer(operation.CompletedAt),
})
}
for _, item := range view.ActionableItems {
if err := validateFiniteWorkflowBudget(item.Budget); err != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, err
}
itemWorkflowID, parseErr := uuid.Parse(item.SourceWorkflowID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
itemRevisionID, parseErr := uuid.Parse(item.SourceRevisionID)
if parseErr != nil {
return tuttigenerated.WorkspaceWorkflowSnapshot{}, parseErr
}
result.ActionableItems = append(result.ActionableItems, tuttigenerated.WorkspaceWorkflowActionableItem{
Id: item.ID,
SourceWorkflowId: itemWorkflowID,
SourceRevisionId: itemRevisionID,
Ordinal: item.Ordinal,
TopicId: item.TopicID,
Execution: generatedTuttiModePlanExecution(item.Execution),
Budget: generatedTuttiModePlanBudget(item.Budget),
Task: generatedTuttiModePlanTask(item.Task),
})
}
return result, nil
}
func workflowTaskAssignmentsFromGenerated(values *[]tuttigenerated.WorkspaceWorkflowTaskAssignment) []workflowbiz.TaskAssignment {
if values == nil || len(*values) == 0 {
return nil
}
result := make([]workflowbiz.TaskAssignment, 0, len(*values))
for _, value := range *values {
result = append(result, workflowbiz.TaskAssignment{
TaskID: value.TaskId,
AgentTargetID: value.AgentTargetId,
ModelPlanID: value.ModelPlanId,
Model: value.Model,
PermissionModeID: value.PermissionModeId,
ReasoningEffort: value.ReasoningEffort,
Parallelizable: value.Parallelizable,
AutoAccept: value.AutoAccept,
})
}
return result
}
func validateFiniteWorkflowBudget(budget tuttimodeplanservice.PlanBudget) error {
if math.IsNaN(budget.QuotaWaterlinePercent) || math.IsInf(budget.QuotaWaterlinePercent, 0) {
return fmt.Errorf("invalid persisted workflow quota waterline percentage")
}
return nil
}
func generatedTuttiModePlanDocument(document tuttimodeplanservice.PlanDocument) tuttigenerated.TuttiModePlanDocument {
tasks := make([]tuttigenerated.TuttiModePlanTask, 0, len(document.Tasks))
for _, task := range document.Tasks {
tasks = append(tasks, generatedTuttiModePlanTask(task))
}
return tuttigenerated.TuttiModePlanDocument{
Schema: tuttigenerated.TuttiModePlanDocumentSchema(document.Schema),
Phase: tuttigenerated.TuttiModePlanDocumentPhase(document.Phase),
Title: document.Title,
TopicId: document.TopicID,
MarkdownBody: document.Body,
Execution: generatedTuttiModePlanExecution(document.Execution),
Budget: generatedTuttiModePlanBudget(document.Budget),
Tasks: tasks,
}
}
func generatedTuttiModePlanExecution(execution tuttimodeplanservice.PlanExecution) tuttigenerated.TuttiModePlanExecution {
return tuttigenerated.TuttiModePlanExecution{
Mode: tuttigenerated.TuttiModePlanExecutionMode(execution.Mode),
ReasoningIntensity: execution.ReasoningIntensity,
OrchestrationIntensity: execution.OrchestrationIntensity,
Effect: execution.Effect,
Speed: execution.Speed,
}
}
func generatedTuttiModePlanBudget(budget tuttimodeplanservice.PlanBudget) tuttigenerated.TuttiModePlanBudget {
return tuttigenerated.TuttiModePlanBudget{
Mode: tuttigenerated.TuttiModePlanBudgetMode(budget.Mode),
TokenLimit: budget.TokenLimit,
QuotaWaterlinePercent: budget.QuotaWaterlinePercent,
}
}
func generatedTuttiModePlanTask(task tuttimodeplanservice.PlanTask) tuttigenerated.TuttiModePlanTask {
return tuttigenerated.TuttiModePlanTask{
Id: task.ID,
Title: task.Title,
Content: task.Content,
Priority: tuttigenerated.TuttiModePlanTaskPriority(task.Priority),
AgentTargetId: stringPointerIfNotBlank(task.AgentTargetID),
ModelPlanId: stringPointerIfNotBlank(task.ModelPlanID),
Model: stringPointerIfNotBlank(task.Model),
PermissionModeId: stringPointerIfNotBlank(task.PermissionModeID),
ReasoningEffort: stringPointerIfNotBlank(task.ReasoningEffort),
ExecutionDirectory: stringPointerIfNotBlank(task.ExecutionDirectory),
DependsOn: append([]string{}, task.DependsOn...),
Parallelizable: task.Parallelizable,
AutoAccept: task.AutoAccept,
}
}
func unixMilliPointer(value time.Time) *int64 {
if value.IsZero() {
return nil
}
result := value.UnixMilli()
return &result
}
func workflowServiceUnavailable() tuttigenerated.ServiceUnavailableErrorJSONResponse {
return serviceUnavailableError(apierrors.ServiceUnavailable("workspace_workflow_service_unavailable"))
}
func workflowProtocolError(err error) *apierrors.ProtocolError {
switch {
case errors.Is(err, tuttimodeplanservice.ErrInvalidInput), errors.Is(err, tuttimodeplanservice.ErrInvalidDecision), errors.Is(err, tuttimodeplanservice.ErrInvalidTransition):
return apierrors.InvalidRequest("invalid_workspace_workflow_request", apierrors.WithCause(err))
case errors.Is(err, workspacedata.ErrWorkspaceWorkflowNotFound), errors.Is(err, workspacedata.ErrWorkflowCheckpointNotFound), errors.Is(err, tuttimodeplanservice.ErrCheckpointMissing):
return apierrors.WorkspaceNotFound("workspace_workflow_not_found", apierrors.WithCause(err))
default:
return apierrors.WorkspaceOperationFailed(apierrors.WithCause(err))
}
}
func listWorkspaceWorkflowsError(err error) tuttigenerated.ListWorkspaceWorkflowsResponseObject {
protocolErr := workflowProtocolError(err)
if protocolErr.Code == tuttigenerated.InvalidRequest {
return tuttigenerated.ListWorkspaceWorkflows400JSONResponse{InvalidRequestErrorJSONResponse: invalidRequestError(protocolErr)}
}
if protocolErr.Code == tuttigenerated.WorkspaceNotFound {
return tuttigenerated.ListWorkspaceWorkflows404JSONResponse{WorkspaceNotFoundErrorJSONResponse: workspaceNotFoundError(protocolErr)}
}
return tuttigenerated.ListWorkspaceWorkflows502JSONResponse{WorkspaceOperationErrorJSONResponse: workspaceOperationError(protocolErr)}
}
func getWorkspaceWorkflowError(err error) tuttigenerated.GetWorkspaceWorkflowResponseObject {
protocolErr := workflowProtocolError(err)
if protocolErr.Code == tuttigenerated.InvalidRequest {
return tuttigenerated.GetWorkspaceWorkflow400JSONResponse{InvalidRequestErrorJSONResponse: invalidRequestError(protocolErr)}
}
if protocolErr.Code == tuttigenerated.WorkspaceNotFound {
return tuttigenerated.GetWorkspaceWorkflow404JSONResponse{WorkspaceNotFoundErrorJSONResponse: workspaceNotFoundError(protocolErr)}
}
return tuttigenerated.GetWorkspaceWorkflow502JSONResponse{WorkspaceOperationErrorJSONResponse: workspaceOperationError(protocolErr)}
}
func decideWorkspaceWorkflowError(err error) tuttigenerated.DecideWorkspaceWorkflowCheckpointResponseObject {
if errors.Is(err, tuttimodeplanservice.ErrDecisionConflict) {
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint409JSONResponse(protocolErrorResponse(apierrors.InvalidRequest("workspace_workflow_decision_conflict", apierrors.WithCause(err))))
}
protocolErr := workflowProtocolError(err)
if protocolErr.Code == tuttigenerated.InvalidRequest {
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint400JSONResponse{InvalidRequestErrorJSONResponse: invalidRequestError(protocolErr)}
}
if protocolErr.Code == tuttigenerated.WorkspaceNotFound {
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint404JSONResponse{WorkspaceNotFoundErrorJSONResponse: workspaceNotFoundError(protocolErr)}
}
return tuttigenerated.DecideWorkspaceWorkflowCheckpoint502JSONResponse{WorkspaceOperationErrorJSONResponse: workspaceOperationError(protocolErr)}
}