nomadcode/services/core/cmd/server/main.go
toki afb26e67ed feat: complete work item creation sync milestone - slot finalizer, live cycle smoke test, archive completed subtasks
- Add slot_finalizer.go for terminal state management
- Add live cycle smoke test documentation
- Archive completed subtasks (slot terminal state, live cycle smoke)
- Update scheduler jobs and workflow service for roadmap sync
- Update authoring request/result handlers
- Update milestone documentation
2026-06-16 10:06:58 +09:00

220 lines
7.2 KiB
Go

package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
agenta2a "github.com/nomadcode/nomadcode-core/internal/adapters/a2a"
"github.com/nomadcode/nomadcode-core/internal/adapters/jira"
"github.com/nomadcode/nomadcode-core/internal/adapters/mattermost"
modelopenai "github.com/nomadcode/nomadcode-core/internal/adapters/openai"
"github.com/nomadcode/nomadcode-core/internal/adapters/plane"
"github.com/nomadcode/nomadcode-core/internal/agent"
"github.com/nomadcode/nomadcode-core/internal/config"
"github.com/nomadcode/nomadcode-core/internal/db"
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
"github.com/nomadcode/nomadcode-core/internal/gitosync"
apphttp "github.com/nomadcode/nomadcode-core/internal/http"
"github.com/nomadcode/nomadcode-core/internal/notification"
"github.com/nomadcode/nomadcode-core/internal/protosocket"
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
"github.com/nomadcode/nomadcode-core/internal/scheduler"
"github.com/nomadcode/nomadcode-core/internal/storage"
"github.com/nomadcode/nomadcode-core/internal/workflow"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
cfg := config.Load()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := db.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
store := storage.NewStore(pool)
mattermostClient := mattermost.NewClient(mattermost.Config{
BaseURL: cfg.MattermostBaseURL,
Token: cfg.MattermostToken,
ChannelID: cfg.MattermostChannelID,
}, logger)
planeClient := plane.NewClient(plane.Config{
BaseURL: cfg.PlaneBaseURL,
Token: cfg.PlaneToken,
}, logger)
jiraClient := jira.NewClient(jira.Config{
BaseURL: cfg.JiraBaseURL,
Email: cfg.JiraEmail,
APIToken: cfg.JiraAPIToken,
}, logger)
if cfg.JiraBaseURL != "" {
logger.Info("jira adapter initialized", "base_url", cfg.JiraBaseURL)
}
protoSocketServer := protosocket.NewServer(protosocket.Config{
HeartbeatIntervalSec: cfg.ProtoSocketHeartbeatIntervalSec,
HeartbeatWaitSec: cfg.ProtoSocketHeartbeatWaitSec,
}, logger)
notificationService := notification.NewService(
logger,
mattermost.NewTaskNotificationSink(mattermostClient),
protosocket.NewTaskEventBroadcaster(protoSocketServer),
)
modelClient := modelopenai.NewClient(modelopenai.Config{
BaseURL: cfg.ModelBaseURL,
APIKey: cfg.ModelAPIKey,
Model: cfg.ModelName,
ContextSize: cfg.ModelContextSize,
TimeoutSec: cfg.ModelTimeoutSec,
}, logger)
var agentClient agent.Client
if cfg.A2AEdgeURL != "" {
agentClient = agenta2a.NewClient(agenta2a.Config{
URL: cfg.A2AEdgeURL,
Token: cfg.A2AToken,
TimeoutSec: cfg.A2ATimeoutSec,
}, logger)
logger.Info("a2a edge input enabled", "url", cfg.A2AEdgeURL)
}
lifecycle := workflow.NewLifecycle(store, logger)
// Plane-origin Milestone creation sync orchestrator. Jira is intentionally
// not wired here; this slice handles the Plane projection path only. The
// self-actor is left empty until a dedicated NomadCode Plane account id is
// configured, so the reconcile's self-mutation guard stays a no-op rather
// than mis-skipping a real trigger.
creationSync := roadmapsyncpipeline.NewService(store, planeClient, "")
workflowService := workflow.NewService(store, nil, logger)
taskScheduler, err := scheduler.New(pool, lifecycle, notificationService, agentClient, modelClient, creationSync, workflowService, store, time.Duration(cfg.WorkflowTaskTimeoutSec)*time.Second, logger)
if err != nil {
return err
}
if err := taskScheduler.Migrate(ctx); err != nil {
return err
}
if err := taskScheduler.Start(ctx); err != nil {
return err
}
workflowService.SetEnqueuer(taskScheduler)
// Gito branch event consumer: drives Plane-origin Milestone creation sync
// from a develop push. Started only when the endpoint, repo, local develop
// checkout, and Todo state id are all configured; otherwise the Core server
// behaves exactly as before. Cancelled with the root context on shutdown.
if cfg.GitoBranchEventsEnabled() {
gitoRunner, err := newGitoRunner(cfg, planeClient, taskScheduler, logger)
if err != nil {
return err
}
go func() {
if err := gitoRunner.Run(ctx); err != nil {
logger.Error("gito branch event runner stopped", "error", err)
}
}()
logger.Info("gito branch event consumer enabled",
"repo_id", cfg.GitoRepoID, "branch", cfg.GitoBranch)
}
projectBinder := apphttp.NewProjectBinder(store)
handler := apphttp.NewHandler(
pool,
workflowService,
projectBinder,
logger,
apphttp.WorkItemProvider{ID: "plane", Reader: planeClient},
apphttp.WorkItemProvider{ID: "jira", Reader: jiraClient},
)
handler.SetPlaneWebhookSecret(cfg.PlaneWebhookSecret)
handler.SetPlaneWebhookDispatchConfig(apphttp.PlaneWebhookDispatchConfig{
WorkspaceID: cfg.PlaneWorkspaceID,
WorkspaceSlug: cfg.PlaneWorkspaceSlug,
BacklogStateID: cfg.PlaneCreationBacklogStateID,
AgentAssigneeID: cfg.PlaneAgentAssigneeID,
SelfActorID: cfg.PlaneSelfActorID,
})
protosocket.NewTaskChannels(workflowService).Register(protoSocketServer.Dispatcher())
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}, protoSocketServer, cfg.ProtoSocketPath),
ReadHeaderTimeout: 5 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
logger.Info("server listening", "addr", cfg.HTTPAddr, "env", cfg.AppEnv)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
return
}
serverErr <- nil
}()
select {
case <-ctx.Done():
case err := <-serverErr:
if err != nil {
return err
}
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return err
}
if err := protoSocketServer.Close(); err != nil {
return err
}
if err := taskScheduler.Stop(shutdownCtx); err != nil {
return err
}
return nil
}
// newGitoRunner assembles the Gito branch event consumer: an exec-backed develop
// scanner, the Plane work item reader, the scheduler enqueuer, and an in-memory
// duplicate-revision guard, wired into a proto-socket runner.
func newGitoRunner(cfg config.Config, reader *plane.Client, enqueuer *scheduler.Client, logger *slog.Logger) (*gitoevents.Client, error) {
scanner, err := gitosync.NewBranchRevisionScanner(gitosync.ExecCommandRunner{}, gitosync.ScannerConfig{
DevelopRepoPath: cfg.GitoDevelopRepoPath,
RemoteName: cfg.GitoRemoteName,
Branch: cfg.GitoBranch,
})
if err != nil {
return nil, err
}
bridge, err := gitosync.NewBridge(scanner, reader, enqueuer, nil, gitosync.BridgeConfig{
RepoID: cfg.GitoRepoID,
Branch: cfg.GitoBranch,
TodoStateID: cfg.RoadmapCreationTodoStateID,
}, logger)
if err != nil {
return nil, err
}
return gitosync.NewRunner(cfg.GitoProtoSocketURL, cfg.GitoRepoID, cfg.GitoBranch, bridge, logger)
}