package core import ( "context" "fmt" "strings" "github.com/kamelion/ratatoskr-go/internal/events" "github.com/kamelion/ratatoskr-go/internal/storage" ) // Core — ядро Ratatoskr: машина состояний поверх storage. type Core struct { // Store — хранилище задач и трасс. Store *storage.Storage Decide Decider MaxTurns int // D3 MaxConfirmCycles int // D4 MaxQuestionsPerTurn int // Live — опциональный просмотр живой сессии задачи (для /status N). Live LiveProber // Events — издатель доменных событий для UI. nil — события выключены. Events events.Publisher } // publish отправляет доменное событие, если задан издатель. func (c *Core) publish(e events.Event) { if c.Events != nil { c.Events.Publish(e) } } // setStatus переводит задачу в новый статус: сохраняет в БД и публикует событие. // from фиксируется до перехода (для TaskStatusChanged). func (c *Core) setStatus(ctx context.Context, task *storage.Task, to storage.Status) error { from := task.Status task.Status = to if err := c.Store.UpdateTask(ctx, task); err != nil { return err } c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: to}) return nil } // LiveProber — абстракция за журналом живых сессий (реализация — *opencode.LiveRegistry). // Возвращает текстовое описание текущего состояния сессии задачи и ok=была ли активна. type LiveProber interface { Probe(taskID int64) (description string, ok bool) } // New создаёт Core с дефолтными лимитами. func New(store *storage.Storage, decide Decider) *Core { return &Core{ Store: store, Decide: decide, MaxTurns: 15, MaxConfirmCycles: 3, MaxQuestionsPerTurn: 5, } } // ProcessTurn обрабатывает один ход для задачи. Меняет состояние в БД. func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Result, error) { text = strings.TrimSpace(text) // 1. команды — обрабатываются до выборки «текущей» задачи, // т.к. /retry N /status N /continue N ссылаются на другую задачу if strings.HasPrefix(text, "/") { return c.handleCommand(ctx, taskID, text) } task, err := c.Store.GetTask(ctx, taskID) if err != nil { return Result{}, err } // 2. согласие в фазе ready → одобрение if task.Status == storage.StatusReady { if isConsent(text) { return c.handleConsent(ctx, task) } // правка черновика → снова сбор return c.handleEdit(ctx, task, text) } // 2b. approved — финальное одобрение, правка запрещена. // Воркер уже взял/заберёт задачу; текст не меняет статус. if task.Status == storage.StatusApproved { return Result{ Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID), TaskID: task.ID, Status: task.Status, }, nil } // 3. обычный ход: накопление + аналитик return c.handleTurn(ctx, task, text) } // greeting — приветствие для /start. const greeting = "⚡ Ratatoskr на связи. Опишите задачу — я помогу её продумать. " + "(команды: /cancel — отмена, /skip — хватит вопросов, /retry N — перезапустить задачу, /status N — статус)" // handleCommand обрабатывает "/"-команды. Минимальный набор. // taskID — текущая задача пользователя (может не существовать для "чужих" команд). func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Result, error) { cmd := text if i := strings.IndexByte(cmd, ' '); i > 0 { cmd = cmd[:i] } cmd = strings.ToLower(cmd) rest := strings.TrimSpace(text[len(cmd):]) switch cmd { case "/start": return c.handleStart(ctx, taskID) case "/cancel": return c.handleCancel(ctx, taskID) case "/skip": return c.handleSkip(ctx, taskID) case "/retry": return c.handleRetry(ctx, rest) case "/status": return c.handleStatus(ctx, rest) case "/continue": return c.handleContinue(ctx, rest) default: return Result{ Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N", TaskID: taskID, }, nil } } // handleStart переводит задачу в сбор (collecting) и чистит историю. // Возвращает greeting. task_tag переиспользуется (стабильный UUID задачи). func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) { task, err := c.Store.GetTask(ctx, taskID) if err != nil { return Result{}, err } if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil { return Result{}, err } if err := c.Store.ClearHistory(ctx, task.ID); err != nil { return Result{}, err } return Result{ Reply: greeting, TaskID: task.ID, Status: task.Status, }, nil } // handleCancel отменяет задачу (терминальный статус cancelled). func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) { task, err := c.Store.GetTask(ctx, taskID) if err != nil { return Result{}, err } if err := c.setStatus(ctx, task, storage.StatusCancelled); err != nil { return Result{}, err } return Result{ Reply: "🚫 Отменил.", TaskID: task.ID, Status: task.Status, }, nil } // handleSkip принудительно предлагает черновик в фазе collecting, либо подсказывает. func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) { task, err := c.Store.GetTask(ctx, taskID) if err != nil { return Result{}, err } if task.Status == storage.StatusReady { return Result{ Reply: "Напишите «создавай» — или правьте текст.", TaskID: task.ID, Status: task.Status, }, nil } // принудительный вызов аналитика return c.runDecide(ctx, task, true) } // parseTaskID извлекает целое из строки после команды. func parseTaskID(s string) (int64, bool) { s = strings.TrimSpace(s) if s == "" { return 0, false } var id int64 for _, r := range s { if r < '0' || r > '9' { return 0, false } id = id*10 + int64(r-'0') } return id, id > 0 } // handleRetry перезапускает задачу N: чистит историю и ставит collecting. func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) { id, ok := parseTaskID(rest) if !ok { return Result{ Reply: "Укажите номер задачи: `/retry 5`.", }, nil } task, err := c.Store.GetTask(ctx, id) if err != nil { return c.notFoundReply(ctx, id, err) } // Завершённые задачи (success/cancelled/aborted/closed) перезапускать нельзя: // переход → collecting для них невалиден. Только новая задача через /start. if storage.IsTerminal(task.Status) { return Result{ Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.", TaskID: id, Status: task.Status, }, nil } if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil { return Result{}, err } if err := c.Store.ClearHistory(ctx, id); err != nil { return Result{}, err } return Result{ Reply: "Задача перезапущена. Опишите, что меняем:", TaskID: id, Status: task.Status, }, nil } // handleStatus возвращает статус задачи N. func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) { id, ok := parseTaskID(rest) if !ok { return Result{ Reply: "Укажите номер задачи: `/status 5`.", }, nil } task, err := c.Store.GetTask(ctx, id) if err != nil { return c.notFoundReply(ctx, id, err) } reply := "Задача #" + itoa(id) + ": " + string(task.Status) // Для выполняемой задачи прикладываем живое состояние сессии (если настроено). if task.Status == storage.StatusRunning && c.Live != nil { if desc, ok := c.Live.Probe(id); ok && desc != "" { reply += "\n" + desc } } return Result{ Reply: reply, TaskID: id, Status: task.Status, }, nil } // handleContinue — заглушка (резюм opencode-сессии), подключим позже. func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error) { id, ok := parseTaskID(rest) if !ok { return Result{ Reply: "Укажите номер задачи: `/continue 5`.", }, nil } task, err := c.Store.GetTask(ctx, id) if err != nil { return c.notFoundReply(ctx, id, err) } return Result{ Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) + ". Резюм сессии — пока не реализован.", TaskID: id, Status: task.Status, }, nil } // notFoundReply формирует reply для ненайденной задачи. func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) { return Result{ Reply: "Задача #" + itoa(id) + " не найдена.", }, nil } // handleConsent одобряет задачу: ready → approved (финальное одобрение, // после которого воркер забирает задачу на выполнение). func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) { if err := c.setStatus(ctx, task, storage.StatusApproved); err != nil { return Result{}, err } return Result{ Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.", TaskID: task.ID, Status: task.Status, }, nil } // handleEdit — правка черновика в фазе ready → снова сбор + аналитик. func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) { from := task.Status task.Status = storage.StatusCollecting if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil { return Result{}, err } if err := c.Store.UpdateTask(ctx, task); err != nil { return Result{}, err } c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status}) return c.runDecide(ctx, task, false) } // handleTurn — обычный ход в фазе collecting: накопление + аналитик. func (c *Core) handleTurn(ctx context.Context, task *storage.Task, text string) (Result, error) { // переход draft→collecting нужно персистить до вызова аналитика, // иначе propose сделает draft→ready (невалидно) if task.Status != storage.StatusCollecting { from := task.Status task.Status = storage.StatusCollecting if err := c.Store.UpdateTask(ctx, task); err != nil { return Result{}, err } c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status}) } if text != "" { if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil { return Result{}, err } } return c.runDecide(ctx, task, false) } // runDecide вызывает аналитика и применяет вердикт. func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (Result, error) { history, err := c.Store.GetHistory(ctx, task.ID) if err != nil { return Result{}, err } if !force && c.MaxTurns > 0 { userTurns := 0 for _, h := range history { if h.Role == "user" { userTurns++ } } if userTurns > c.MaxTurns { return Result{ Reply: "Превышен лимит ходов сбора (" + itoa(int64(c.MaxTurns)) + "). Используйте /skip чтобы сформулировать черновик, или /start для новой задачи.", TaskID: task.ID, Status: task.Status, }, nil } } msgs := make([]Message, 0, len(history)) for _, h := range history { msgs = append(msgs, Message{Role: h.Role, Content: h.Content}) } decision, err := c.Decide.Decide(ctx, msgs, *task, force) if err != nil { return Result{}, fmt.Errorf("%w: %v", ErrDecideFailed, err) } switch decision.Phase { case "abort": if err := c.setStatus(ctx, task, storage.StatusAborted); err != nil { return Result{}, err } reply := decision.ChatReply if reply == "" { reply = "Недостаточно данных. Начните заново (/start)." } return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil case "propose", "ready": // применяем черновик (для ready — текущий, без изменений) applyDraft(task, decision.Draft) // E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить. if len(task.EffectiveRepos()) == 0 { if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil { return Result{}, err } reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.") return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil } if err := c.setStatus(ctx, task, storage.StatusReady); err != nil { return Result{}, err } return Result{ Reply: formatSummary(*task), TaskID: task.ID, Status: task.Status, }, nil default: // "ask" applyDraft(task, decision.Draft) if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil { return Result{}, err } reply := buildAskReply(decision, c.MaxQuestionsPerTurn) return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil } } // applyDraft копирует непустые draft-поля из вердикта в задачу. func applyDraft(task *storage.Task, dec storage.Task) { if dec.Title != "" { task.Title = dec.Title } if dec.Goal != "" { task.Goal = dec.Goal } if dec.Repo != "" { task.Repo = dec.Repo } if len(dec.Repos) > 0 { task.Repos = dec.Repos task.Repo = strings.Join(dec.Repos, ",") } if dec.Why != "" { task.Why = dec.Why } if dec.AC != "" { task.AC = dec.AC } } // decChatReply возвращает ChatReply из вердикта или переданный дефолт. func decChatReply(dec Decision, fallback string) string { if dec.ChatReply != "" { return dec.ChatReply } return fallback } // buildAskReply формирует ответ с вопросами. func buildAskReply(dec Decision, max int) string { reply := dec.ChatReply if reply == "" { reply = "Уточню детали." } if len(dec.Questions) == 0 { return reply } qs := dec.Questions if len(qs) > max { qs = qs[:max] } lines := []string{reply} for i, q := range qs { lines = append(lines, itoa(int64(i+1))+". "+q) } if len(dec.Questions) > max { lines = append(lines, "(и ещё вопросы по ходу)") } return strings.Join(lines, "\n") } // formatSummary возвращает сводку черновика в фазе ready. func formatSummary(t storage.Task) string { var b strings.Builder b.WriteString("📋 **Черновик задачи #" + itoa(t.ID) + "** готов.\n\n") if t.Title != "" { b.WriteString("**Название:** " + t.Title + "\n") } if len(t.EffectiveRepos()) > 0 { b.WriteString("**Репозитории:** " + strings.Join(t.EffectiveRepos(), ", ") + "\n") } if t.Goal != "" { b.WriteString("**Цель:** " + t.Goal + "\n") } if t.Why != "" { b.WriteString("**Зачем:** " + t.Why + "\n") } if t.AC != "" { b.WriteString("**Критерии:**\n" + t.AC + "\n") } b.WriteString("\nНапишите «создавай» — или правьте текст.") return b.String() } // itoa — мини int→string без strconv. func itoa(n int64) string { if n == 0 { return "0" } var buf [20]byte i := len(buf) neg := n < 0 if neg { n = -n } for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) }