Files
kandev/apps/backend/internal/backendapp/adapters_office.go
T
2026-06-20 19:40:15 +01:00

314 lines
10 KiB
Go

package backendapp
import (
"context"
"database/sql"
"fmt"
"github.com/kandev/kandev/internal/github"
officeconfig "github.com/kandev/kandev/internal/office/config"
"github.com/kandev/kandev/internal/office/configloader"
officedashboard "github.com/kandev/kandev/internal/office/dashboard"
officeengineadapters "github.com/kandev/kandev/internal/office/engine_adapters"
officeonboarding "github.com/kandev/kandev/internal/office/onboarding"
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
officeroutines "github.com/kandev/kandev/internal/office/routines"
officeservice "github.com/kandev/kandev/internal/office/service"
officewakeup "github.com/kandev/kandev/internal/office/wakeup"
"github.com/kandev/kandev/internal/task/models"
tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
)
// taskWorkspaceCreatorAdapter adapts the task service to the office
// WorkspaceCreator interface for dual workspace creation.
type taskWorkspaceCreatorAdapter struct {
taskSvc *taskservice.Service
}
func (a *taskWorkspaceCreatorAdapter) CreateWorkspace(ctx context.Context, name, description string) error {
_, err := a.taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{
Name: name,
Description: description,
})
return err
}
func (a *taskWorkspaceCreatorAdapter) FindWorkspaceIDByName(ctx context.Context, name string) (string, error) {
workspaces, err := a.taskSvc.ListWorkspaces(ctx)
if err != nil {
return "", err
}
for _, ws := range workspaces {
if ws.Name == name {
return ws.ID, nil
}
}
return "", nil
}
func (a *taskWorkspaceCreatorAdapter) ListWorkspaceNames(ctx context.Context) ([]string, error) {
workspaces, err := a.taskSvc.ListWorkspaces(ctx)
if err != nil {
return nil, err
}
names := make([]string, len(workspaces))
for i, ws := range workspaces {
names[i] = ws.Name
}
return names, nil
}
// taskPRListerAdapter adapts *github.Service to the office TaskPRLister
// interface so the children-completed payload can carry per-child PR URLs.
// Returns an empty map (no PRs) when the github service is nil.
type taskPRListerAdapter struct {
gh *github.Service
}
func (a *taskPRListerAdapter) ListTaskPRsByTaskIDs(
ctx context.Context, taskIDs []string,
) (map[string][]officeservice.TaskPRLink, error) {
out := make(map[string][]officeservice.TaskPRLink)
if a.gh == nil || len(taskIDs) == 0 {
return out, nil
}
prs, err := a.gh.ListTaskPRsByTaskIDs(ctx, taskIDs)
if err != nil {
return nil, err
}
for taskID, list := range prs {
links := make([]officeservice.TaskPRLink, 0, len(list))
for _, pr := range list {
if pr == nil {
continue
}
links = append(links, officeservice.TaskPRLink{
URL: pr.PRURL,
Title: pr.PRTitle,
Number: pr.PRNumber,
State: pr.State,
})
}
if len(links) > 0 {
out[taskID] = links
}
}
return out, nil
}
// childTaskCreatorAdapter bridges *taskservice.Service to the
// engine_adapters.ChildTaskCreator interface. The two structs use
// identical fields but live in different packages — this adapter copies
// the fields field-by-field to keep the dependency graph acyclic.
type childTaskCreatorAdapter struct {
taskSvc *taskservice.Service
}
func (a *childTaskCreatorAdapter) CreateChildTask(
ctx context.Context, parent *models.Task, spec officeengineadapters.ChildTaskCreateSpec,
) (string, error) {
return a.taskSvc.CreateChildTask(ctx, parent, taskservice.ChildTaskSpec{
Title: spec.Title,
Description: spec.Description,
WorkflowID: spec.WorkflowID,
StepID: spec.StepID,
AgentProfileID: spec.AgentProfileID,
})
}
// taskCreatorAdapter adapts the task service to the office TaskCreator interface.
type taskCreatorAdapter struct {
taskSvc *taskservice.Service
}
func (a *taskCreatorAdapter) CreateOfficeTask(ctx context.Context, workspaceID, projectID, assigneeAgentID, title, description string) (string, error) {
task, err := a.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ //nolint:exhaustruct
WorkspaceID: workspaceID,
Title: title,
Description: description,
ProjectID: projectID,
AssigneeAgentProfileID: assigneeAgentID,
Origin: models.TaskOriginOnboarding,
})
if err != nil {
return "", err
}
return task.ID, nil
}
// CreateOfficeTaskInWorkflow creates an office task pinned to a specific
// workflow id, bypassing the workspace's default office_workflow_id. Used
// for the standing coordination task that lives on the dedicated
// Coordination workflow.
func (a *taskCreatorAdapter) CreateOfficeTaskInWorkflow(
ctx context.Context, workspaceID, projectID, assigneeAgentID, workflowID, title, description string,
) (string, error) {
task, err := a.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ //nolint:exhaustruct
WorkspaceID: workspaceID,
WorkflowID: workflowID,
Title: title,
Description: description,
ProjectID: projectID,
AssigneeAgentProfileID: assigneeAgentID,
Origin: models.TaskOriginOnboarding,
})
if err != nil {
return "", err
}
return task.ID, nil
}
func (a *taskCreatorAdapter) CreateOfficeSubtask(
ctx context.Context,
parentTaskID, assigneeAgentID, title, description string,
) (string, error) {
parent, err := a.taskSvc.GetTask(ctx, parentTaskID)
if err != nil {
return "", err
}
return a.taskSvc.CreateChildTask(ctx, parent, taskservice.ChildTaskSpec{
Title: title,
Description: description,
AgentProfileID: assigneeAgentID,
})
}
// routineWakeupAdapter bridges the routines package's WakeupEnqueuer
// interface to the concrete office sqlite repo + wakeup dispatcher.
// Defined here (not in the routines package) so the routines package
// stays free of office/repository/sqlite imports — keeping the
// dependency direction routines → wakeup (via interface) and never the
// reverse.
type routineWakeupAdapter struct {
repo *officesqlite.Repository
dispatcher *officewakeup.Dispatcher
}
func (a *routineWakeupAdapter) CreateWakeupRequest(
ctx context.Context, req *officeroutines.WakeupRequest,
) error {
row := &officesqlite.WakeupRequest{
ID: req.ID,
AgentProfileID: req.AgentProfileID,
Source: req.Source,
Reason: req.Reason,
Payload: req.Payload,
RequestedAt: req.RequestedAt,
}
if req.IdempotencyKey != "" {
row.IdempotencyKey = sql.NullString{String: req.IdempotencyKey, Valid: true}
}
return a.repo.CreateWakeupRequest(ctx, row)
}
func (a *routineWakeupAdapter) Dispatch(ctx context.Context, requestID string) error {
return a.dispatcher.Dispatch(ctx, requestID)
}
// configSyncerAdapter bridges config.ConfigService to the onboarding.ConfigSyncer
// interface by converting config.ImportResult to onboarding.ApplyResult.
type configSyncerAdapter struct {
svc *officeconfig.ConfigService
}
func (a *configSyncerAdapter) ApplyIncoming(ctx context.Context, workspaceID string) (*officeonboarding.ApplyResult, error) {
result, err := a.svc.ApplyIncoming(ctx, workspaceID)
if err != nil {
return nil, err
}
return &officeonboarding.ApplyResult{
CreatedCount: result.CreatedCount,
UpdatedCount: result.UpdatedCount,
}, nil
}
// workflowEnsurerAdapter wraps the task repo to satisfy onboarding.WorkflowEnsurer.
type workflowEnsurerAdapter struct {
repo *tasksqlite.Repository
}
func (a *workflowEnsurerAdapter) EnsureOfficeWorkflow(ctx context.Context, workspaceID string) (string, error) {
return a.repo.EnsureOfficeWorkflow(ctx, workspaceID)
}
func (a *workflowEnsurerAdapter) EnsureOfficeDefaultWorkflow(ctx context.Context, workspaceID string) (string, error) {
return a.repo.EnsureOfficeDefaultWorkflow(ctx, workspaceID)
}
func (a *workflowEnsurerAdapter) EnsureRoutineWorkflow(ctx context.Context, workspaceID string) (string, error) {
return a.repo.EnsureRoutineWorkflow(ctx, workspaceID)
}
// workspaceSettingsProviderAdapter implements dashboard.SettingsProvider using
// the filesystem-based config loader and writer.
type workspaceSettingsProviderAdapter struct {
loader *configloader.ConfigLoader
writer *configloader.FileWriter
}
func (a *workspaceSettingsProviderAdapter) GetSettings(workspaceName string) (*officedashboard.WorkspaceSettings, error) {
ws, err := a.loader.GetWorkspace(workspaceName)
if err != nil {
// Workspace not found in config loader — return defaults.
return &officedashboard.WorkspaceSettings{
Name: workspaceName,
PermissionHandlingMode: "human",
RecoveryLookbackHours: 24,
}, nil
}
mode := string(ws.Settings.PermissionHandlingMode)
if mode == "" {
mode = "human"
}
lookback := ws.Settings.RecoveryLookbackHours
if lookback == 0 {
lookback = 24
}
return &officedashboard.WorkspaceSettings{
Name: ws.Settings.Name,
Description: ws.Settings.Description,
PermissionHandlingMode: mode,
RecoveryLookbackHours: lookback,
}, nil
}
func (a *workspaceSettingsProviderAdapter) UpdatePermissionHandlingMode(workspaceName, mode string) error {
ws, err := a.loader.GetWorkspace(workspaceName)
if err != nil {
// Workspace not in loader — write a minimal kandev.yml.
ws = &configloader.WorkspaceConfig{
Name: workspaceName,
Settings: configloader.WorkspaceSettings{
Name: workspaceName,
},
}
}
settings := ws.Settings
settings.PermissionHandlingMode = configloader.PermissionHandlingMode(mode)
data, err := configloader.MarshalSettings(settings)
if err != nil {
return fmt.Errorf("marshal settings: %w", err)
}
return a.writer.WriteRawSettings(workspaceName, data)
}
func (a *workspaceSettingsProviderAdapter) UpdateRecoveryLookbackHours(workspaceName string, hours int) error {
ws, err := a.loader.GetWorkspace(workspaceName)
if err != nil {
ws = &configloader.WorkspaceConfig{
Name: workspaceName,
Settings: configloader.WorkspaceSettings{
Name: workspaceName,
},
}
}
settings := ws.Settings
settings.RecoveryLookbackHours = hours
data, err := configloader.MarshalSettings(settings)
if err != nil {
return fmt.Errorf("marshal settings: %w", err)
}
return a.writer.WriteRawSettings(workspaceName, data)
}