chore(repo): reinitialize repository
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/role"
|
||||
)
|
||||
|
||||
type builtinRoleSeed struct {
|
||||
Definition role.Definition
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
var builtinRoleSeeds = []builtinRoleSeed{
|
||||
{
|
||||
Definition: role.Definition{
|
||||
Name: "user",
|
||||
Title: "User",
|
||||
ExecutorKind: role.ExecutorKindHuman,
|
||||
Description: "Interactive human participant.",
|
||||
IsEnabled: false,
|
||||
IsBuiltin: true,
|
||||
SortOrder: -100,
|
||||
},
|
||||
SystemPrompt: `你是 user。
|
||||
|
||||
这个角色代表真实用户,不会由 AI runtime 自动执行。`,
|
||||
},
|
||||
{
|
||||
Definition: role.Definition{
|
||||
Name: "leader",
|
||||
Title: "Leader",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
Description: "Runs on the host, talks to the user, maintains the task graph, and orchestrates execution.",
|
||||
IsEnabled: true,
|
||||
IsBuiltin: true,
|
||||
SortOrder: 100,
|
||||
},
|
||||
SystemPrompt: `你是 leader。
|
||||
|
||||
你运行在宿主机上,是当前主题唯一的编排者。你负责与用户沟通、澄清目标、维护 task graph、启动执行,并汇总最终结果。lane 由系统从任务图自动派生。
|
||||
|
||||
规则:
|
||||
- 先理解用户目标,再拆分任务;不要直接跳过澄清。
|
||||
- 所有 task 都必须明确目标、依赖、验收标准。
|
||||
- worker 的问题和总结都只作为编排输入,不要把它们直接当作完成证明。
|
||||
- worker 只能向你升级阻塞;是否打扰用户由你决定。
|
||||
- 你可以创建、更新、启动、停止 lane 与 task,但不要代替 worker 在容器里完成实现。`,
|
||||
},
|
||||
{
|
||||
Definition: role.Definition{
|
||||
Name: "worker",
|
||||
Title: "Worker",
|
||||
ExecutorKind: role.ExecutorKindCodex,
|
||||
Description: "Runs inside a per-lane container, executes tasks in that lane's worktree, and reports back to the leader.",
|
||||
IsEnabled: true,
|
||||
IsBuiltin: true,
|
||||
SortOrder: 200,
|
||||
},
|
||||
SystemPrompt: `你是 worker。
|
||||
|
||||
你运行在一个 lane 对应的独立 worktree 与容器中。你只负责执行当前被分派的 task,并通过 inbox 向 leader 汇报进度、结果或阻塞。
|
||||
|
||||
规则:
|
||||
- 只处理当前 task,不要自行扩展范围。
|
||||
- 在开始前阅读 task、验收标准和 lane 上下文。
|
||||
- 遇到阻塞时,先通过 inbox 向 leader 发一条具体问题,再结束当前 task,并在总结里写清阻塞点与所需决策。
|
||||
- 行为变化时补充必要的验证与测试。`,
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Store) ensureBuiltinRoles(ctx context.Context) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin builtin role seed: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := s.now()
|
||||
for _, seed := range builtinRoleSeeds {
|
||||
if err := seedBuiltinRoleTx(ctx, tx, now, seed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit builtin role seed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedBuiltinRoleTx(ctx context.Context, tx *sql.Tx, now string, seed builtinRoleSeed) error {
|
||||
if _, err := getRoleTx(ctx, tx, seed.Definition.Name); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err := insertBuiltinRoleTx(ctx, tx, seed, now); err != nil {
|
||||
return fmt.Errorf("insert builtin role %q: %w", seed.Definition.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := getRolePromptTx(ctx, tx, seed.Definition.Name, "", role.PromptSystem); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO role_prompts(id, role_name, workspace_id, prompt_kind, content_markdown, version, updated_by, created_at, updated_at)
|
||||
VALUES(?, ?, NULL, ?, ?, 1, 'builtin-seed', ?, ?)
|
||||
`,
|
||||
"builtin-role-prompt-"+seed.Definition.Name,
|
||||
seed.Definition.Name,
|
||||
string(role.PromptSystem),
|
||||
seed.SystemPrompt,
|
||||
now,
|
||||
now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert builtin role prompt %q: %w", seed.Definition.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertBuiltinRoleTx(ctx context.Context, tx *sql.Tx, seed builtinRoleSeed, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO roles(name, title, executor_kind, description, is_enabled, is_builtin, sort_order, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
seed.Definition.Name,
|
||||
seed.Definition.Title,
|
||||
string(seed.Definition.ExecutorKind),
|
||||
seed.Definition.Description,
|
||||
boolToInt(seed.Definition.IsEnabled),
|
||||
boolToInt(seed.Definition.IsBuiltin),
|
||||
seed.Definition.SortOrder,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/skill"
|
||||
)
|
||||
|
||||
type builtinSkillSeed struct {
|
||||
Definition skill.Definition
|
||||
RoleNames []string
|
||||
}
|
||||
|
||||
const inboxSkillMarkdown = `---
|
||||
name: inbox
|
||||
description: "Use Inbox V2 from a role runtime. Supports two operations only: send and ask. Use this when you need to post a workflow message or a blocking question into an existing topic with inbox api."
|
||||
---
|
||||
|
||||
# Inbox
|
||||
|
||||
Use this skill when a role needs to communicate through Inbox V2.
|
||||
|
||||
This skill has two operations only:
|
||||
- send: post a normal workflow message into an existing topic
|
||||
- ask: post a blocking question that needs an answer from another role
|
||||
|
||||
## Before using either operation
|
||||
|
||||
Resolve the topic id first if you only know the workspace and topic slug:
|
||||
|
||||
inbox api GET "/api/v2/topics?workspace_id=<workspace_id>"
|
||||
|
||||
Pick the topic record you want, then use its id in the message API below.
|
||||
|
||||
## send
|
||||
|
||||
Use send for handoffs, updates, decisions, or summaries.
|
||||
|
||||
inbox api POST /api/v2/topics/<topic_id>/messages --data '{
|
||||
"workspace_id": "<workspace_id>",
|
||||
"from_role_name": "<current_role>",
|
||||
"to_expr": "<target_role>",
|
||||
"type": "chat",
|
||||
"stage": "execution",
|
||||
"body_markdown": "<message markdown>"
|
||||
}'
|
||||
|
||||
Rules:
|
||||
- Set type to the actual intent: chat, proposal, decision, or summary.
|
||||
- Keep stage aligned with the current workflow state.
|
||||
- Add reply_to_message_id when replying to a specific message.
|
||||
|
||||
## ask
|
||||
|
||||
Use ask when you are blocked and need a concrete answer.
|
||||
|
||||
inbox api POST /api/v2/topics/<topic_id>/messages --data '{
|
||||
"workspace_id": "<workspace_id>",
|
||||
"from_role_name": "<current_role>",
|
||||
"to_expr": "<target_role>",
|
||||
"type": "question",
|
||||
"stage": "<current_stage>",
|
||||
"body_markdown": "<single concrete question and the decision you need>"
|
||||
}'
|
||||
|
||||
Rules:
|
||||
- Ask one concrete blocker per message.
|
||||
- State the decision, artifact, or approval you need back.
|
||||
- Keep stage aligned with the task or planning stage you are in.
|
||||
- Prefer ask over vague status pings.`
|
||||
|
||||
var builtinSkillSeeds = []builtinSkillSeed{
|
||||
{
|
||||
Definition: skill.Definition{
|
||||
SkillKey: "inbox",
|
||||
Name: "Inbox",
|
||||
Description: "Use Inbox V2 from a role runtime with two operations: send and ask.",
|
||||
SourceType: "capability",
|
||||
ContentMarkdown: inboxSkillMarkdown,
|
||||
Status: "active",
|
||||
},
|
||||
RoleNames: []string{
|
||||
"leader",
|
||||
"worker",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Store) ensureBuiltinSkills(ctx context.Context) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin builtin skill seed: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := s.now()
|
||||
for _, seed := range builtinSkillSeeds {
|
||||
if err := s.seedBuiltinSkillTx(ctx, tx, now, seed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit builtin skill seed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) seedBuiltinSkillTx(ctx context.Context, tx *sql.Tx, now string, seed builtinSkillSeed) error {
|
||||
item, err := getSkillByKeyTx(ctx, tx, seed.Definition.SkillKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
item = seed.Definition
|
||||
item.ID = "builtin-skill-" + seed.Definition.SkillKey
|
||||
item.Version = 1
|
||||
item.CreatedAt = now
|
||||
item.UpdatedAt = now
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO skills(id, skill_key, name, description, source_type, content_markdown, status, version, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
item.ID,
|
||||
item.SkillKey,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.SourceType,
|
||||
item.ContentMarkdown,
|
||||
item.Status,
|
||||
item.Version,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert builtin skill %q: %w", item.SkillKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, roleName := range seed.RoleNames {
|
||||
if err := seedBuiltinRoleSkillBindingTx(ctx, tx, now, roleName, item.ID, item.SkillKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedBuiltinRoleSkillBindingTx(ctx context.Context, tx *sql.Tx, now, roleName, skillID, skillKey string) error {
|
||||
if _, err := getRoleSkillBindingTx(ctx, tx, roleName, "", skillID); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO role_skill_bindings(id, role_name, workspace_id, skill_id, is_enabled, sort_order, config_json, version, updated_by, created_at, updated_at)
|
||||
VALUES(?, ?, NULL, ?, 1, 100, ?, 1, 'builtin-seed', ?, ?)
|
||||
`,
|
||||
"builtin-role-skill-binding-"+roleName+"-"+skillKey,
|
||||
roleName,
|
||||
skillID,
|
||||
`{"category":"capability"}`,
|
||||
now,
|
||||
now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert builtin role skill binding %q/%q: %w", roleName, skillKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/base/idgen"
|
||||
"inbox/internal/base/timeutil"
|
||||
)
|
||||
|
||||
func (s *Store) now() string {
|
||||
return timeutil.FormatRFC3339(s.clock.Now())
|
||||
}
|
||||
|
||||
func (s *Store) newID(kind string) (string, error) {
|
||||
return idgen.NewGenerator(s.clock, nil).New(kind)
|
||||
}
|
||||
|
||||
type versionedConfigCurrent struct {
|
||||
ID string
|
||||
Version int
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type versionedConfigMetadata struct {
|
||||
Action string
|
||||
ID string
|
||||
Version int
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type versionedConfigUpsertSpec[T any] struct {
|
||||
entityName string
|
||||
idKind string
|
||||
loadTx func(context.Context, *sql.Tx) (T, error)
|
||||
current func(T) versionedConfigCurrent
|
||||
applyMetadata func(*T, versionedConfigMetadata)
|
||||
writeTx func(context.Context, *sql.Tx, T) error
|
||||
}
|
||||
|
||||
func upsertVersionedConfig[T any](ctx context.Context, s *Store, value T, changedBy string, spec versionedConfigUpsertSpec[T]) (T, error) {
|
||||
var zero T
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("begin upsert %s: %w", spec.entityName, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
before, err := spec.loadTx(ctx, tx)
|
||||
exists := err == nil
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return zero, err
|
||||
}
|
||||
before = zero
|
||||
}
|
||||
|
||||
meta, err := s.nextVersionedConfigMetadata(spec.idKind, spec.current(before), exists)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
spec.applyMetadata(&value, meta)
|
||||
|
||||
if err := spec.writeTx(ctx, tx, value); err != nil {
|
||||
return zero, fmt.Errorf("upsert %s: %w", spec.entityName, err)
|
||||
}
|
||||
|
||||
after, err := spec.loadTx(ctx, tx)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return zero, fmt.Errorf("commit upsert %s: %w", spec.entityName, err)
|
||||
}
|
||||
return after, nil
|
||||
}
|
||||
|
||||
func (s *Store) nextVersionedConfigMetadata(idKind string, current versionedConfigCurrent, exists bool) (versionedConfigMetadata, error) {
|
||||
now := s.now()
|
||||
meta := versionedConfigMetadata{
|
||||
Action: "update",
|
||||
ID: current.ID,
|
||||
Version: current.Version + 1,
|
||||
CreatedAt: current.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if exists {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
id, err := s.newID(idKind)
|
||||
if err != nil {
|
||||
return versionedConfigMetadata{}, err
|
||||
}
|
||||
meta.Action = "create"
|
||||
meta.ID = id
|
||||
meta.Version = 1
|
||||
meta.CreatedAt = now
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func marshalJSONMapString(v map[string]string) (string, error) {
|
||||
if len(v) == 0 {
|
||||
return "{}", nil
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal json map string: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func marshalJSONMapAny(v map[string]any) (string, error) {
|
||||
if len(v) == 0 {
|
||||
return "{}", nil
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal json map: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func unmarshalJSON(src string, dst any) error {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
src = "{}"
|
||||
}
|
||||
return json.Unmarshal([]byte(src), dst)
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func coalesceString(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/skill"
|
||||
)
|
||||
|
||||
func TestMigrateCreatesV2Tables(t *testing.T) {
|
||||
store, err := OpenInMemory(timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
for _, table := range []string{
|
||||
"schema_migrations",
|
||||
"roles",
|
||||
"role_prompts",
|
||||
"skills",
|
||||
"role_skill_bindings",
|
||||
"topics",
|
||||
"messages",
|
||||
"message_deliveries",
|
||||
"human_tasks",
|
||||
"lanes",
|
||||
"lane_syncs",
|
||||
"tasks",
|
||||
"task_dependencies",
|
||||
"task_events",
|
||||
"workflow_runs",
|
||||
"workflow_run_logs",
|
||||
} {
|
||||
var count int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = ?`, table).Scan(&count); err != nil {
|
||||
t.Fatalf("check table %s: %v", table, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected table %s to exist", table)
|
||||
}
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'role_threads'`).Scan(&count); err != nil {
|
||||
t.Fatalf("check dropped table role_threads: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected role_threads to be dropped, got count=%d", count)
|
||||
}
|
||||
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'config_change_logs'`).Scan(&count); err != nil {
|
||||
t.Fatalf("check dropped table config_change_logs: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected config_change_logs to be dropped, got count=%d", count)
|
||||
}
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'topic_documents'`).Scan(&count); err != nil {
|
||||
t.Fatalf("check dropped table topic_documents: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected topic_documents to be dropped, got count=%d", count)
|
||||
}
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'merge_requests'`).Scan(&count); err != nil {
|
||||
t.Fatalf("check dropped table merge_requests: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected merge_requests to be dropped, got count=%d", count)
|
||||
}
|
||||
|
||||
rows, err := store.DB().Query(`PRAGMA table_info(workspaces)`)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(workspaces): %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
t.Fatalf("scan workspace column: %v", err)
|
||||
}
|
||||
if name == "runtime_ref" || name == "runtime_endpoint" {
|
||||
t.Fatalf("expected %s column to be dropped from workspaces", name)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate workspace columns: %v", err)
|
||||
}
|
||||
|
||||
runRows, err := store.DB().Query(`PRAGMA table_info(workflow_runs)`)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(workflow_runs): %v", err)
|
||||
}
|
||||
defer runRows.Close()
|
||||
|
||||
for runRows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := runRows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
t.Fatalf("scan workflow_runs column: %v", err)
|
||||
}
|
||||
if name == "thread_id" || name == "prior_thread_id" {
|
||||
t.Fatalf("expected %s column to be dropped from workflow_runs", name)
|
||||
}
|
||||
}
|
||||
if err := runRows.Err(); err != nil {
|
||||
t.Fatalf("iterate workflow_runs columns: %v", err)
|
||||
}
|
||||
|
||||
topicRows, err := store.DB().Query(`PRAGMA table_info(topics)`)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(topics): %v", err)
|
||||
}
|
||||
defer topicRows.Close()
|
||||
|
||||
for topicRows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := topicRows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
t.Fatalf("scan topics column: %v", err)
|
||||
}
|
||||
if name == "source_topic_id" || name == "owner_role_name" || name == "meta_json" {
|
||||
t.Fatalf("expected %s column to be dropped from topics", name)
|
||||
}
|
||||
}
|
||||
if err := topicRows.Err(); err != nil {
|
||||
t.Fatalf("iterate topics columns: %v", err)
|
||||
}
|
||||
|
||||
messageRows, err := store.DB().Query(`PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(messages): %v", err)
|
||||
}
|
||||
defer messageRows.Close()
|
||||
|
||||
for messageRows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := messageRows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
t.Fatalf("scan messages column: %v", err)
|
||||
}
|
||||
if name == "round" {
|
||||
t.Fatalf("expected %s column to be dropped from messages", name)
|
||||
}
|
||||
}
|
||||
if err := messageRows.Err(); err != nil {
|
||||
t.Fatalf("iterate messages columns: %v", err)
|
||||
}
|
||||
|
||||
deliveryRows, err := store.DB().Query(`PRAGMA table_info(message_deliveries)`)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(message_deliveries): %v", err)
|
||||
}
|
||||
defer deliveryRows.Close()
|
||||
|
||||
for deliveryRows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := deliveryRows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
t.Fatalf("scan message_deliveries column: %v", err)
|
||||
}
|
||||
if name == "read_at" || name == "archived_at" {
|
||||
t.Fatalf("expected %s column to be dropped from message_deliveries", name)
|
||||
}
|
||||
}
|
||||
if err := deliveryRows.Err(); err != nil {
|
||||
t.Fatalf("iterate message_deliveries columns: %v", err)
|
||||
}
|
||||
|
||||
roleRows, err := store.DB().Query(`PRAGMA table_info(roles)`)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(roles): %v", err)
|
||||
}
|
||||
defer roleRows.Close()
|
||||
|
||||
foundConfigTOML := false
|
||||
foundAuthJSON := false
|
||||
for roleRows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := roleRows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
t.Fatalf("scan roles column: %v", err)
|
||||
}
|
||||
if name == "config_toml" {
|
||||
foundConfigTOML = true
|
||||
}
|
||||
if name == "auth_json" {
|
||||
foundAuthJSON = true
|
||||
}
|
||||
}
|
||||
if err := roleRows.Err(); err != nil {
|
||||
t.Fatalf("iterate roles columns: %v", err)
|
||||
}
|
||||
if !foundConfigTOML || !foundAuthJSON {
|
||||
t.Fatalf("expected roles table to contain config_toml and auth_json columns")
|
||||
}
|
||||
|
||||
taskRows, err := store.DB().Query(`PRAGMA table_info(tasks)`)
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(tasks): %v", err)
|
||||
}
|
||||
defer taskRows.Close()
|
||||
|
||||
for taskRows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := taskRows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
t.Fatalf("scan tasks column: %v", err)
|
||||
}
|
||||
if name == "gate_policy" || name == "verification_mode" || name == "replan_policy" {
|
||||
t.Fatalf("expected %s column to be dropped from tasks", name)
|
||||
}
|
||||
}
|
||||
if err := taskRows.Err(); err != nil {
|
||||
t.Fatalf("iterate tasks columns: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenInMemorySeedsBuiltinRoles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := OpenInMemory(timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
roles, err := store.ListRoles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRoles() error = %v", err)
|
||||
}
|
||||
if len(roles) != 3 {
|
||||
t.Fatalf("expected 3 builtin roles, got %d", len(roles))
|
||||
}
|
||||
|
||||
skills, err := store.ListSkills(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSkills() error = %v", err)
|
||||
}
|
||||
if len(skills) != 1 {
|
||||
t.Fatalf("expected 1 builtin skill, got %d", len(skills))
|
||||
}
|
||||
if skills[0].SkillKey != "inbox" {
|
||||
t.Fatalf("expected builtin skill inbox, got %#v", skills[0])
|
||||
}
|
||||
|
||||
for _, roleName := range []string{"leader", "worker"} {
|
||||
bindings, err := store.ListRoleSkillBindings(ctx, roleName)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRoleSkillBindings(%s) error = %v", roleName, err)
|
||||
}
|
||||
if len(bindings) != 1 {
|
||||
t.Fatalf("expected 1 builtin skill binding for %s, got %d", roleName, len(bindings))
|
||||
}
|
||||
if bindings[0].SkillID != skills[0].ID || !bindings[0].IsEnabled {
|
||||
t.Fatalf("unexpected builtin binding for %s: %#v", roleName, bindings[0])
|
||||
}
|
||||
}
|
||||
|
||||
prompts, err := store.ListRolePrompts(ctx, "leader")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRolePrompts(leader) error = %v", err)
|
||||
}
|
||||
if len(prompts) == 0 {
|
||||
t.Fatalf("expected builtin leader prompt to be seeded")
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'config_change_logs'`).Scan(&count); err != nil {
|
||||
t.Fatalf("check dropped table config_change_logs: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected config_change_logs to stay dropped after seeding, got count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRemovesLegacyRolesFromExistingDatabase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)}
|
||||
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
if err := configure(db); err != nil {
|
||||
t.Fatalf("configure() error = %v", err)
|
||||
}
|
||||
if err := ensureSchemaMigrationsTable(db); err != nil {
|
||||
t.Fatalf("ensureSchemaMigrationsTable() error = %v", err)
|
||||
}
|
||||
|
||||
for _, migration := range migrations[:7] {
|
||||
if err := applyMigration(ctx, db, migration, clock); err != nil {
|
||||
t.Fatalf("applyMigration(%s) error = %v", migration.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
store := New(db, clock)
|
||||
|
||||
legacyRoles := []struct {
|
||||
name string
|
||||
title string
|
||||
category string
|
||||
description string
|
||||
sortOrder int
|
||||
}{
|
||||
{"product", "Product", "product", "Owns scope freeze.", 100},
|
||||
{"backend", "Backend", "delivery", "Implements backend logic.", 200},
|
||||
{"frontend", "Frontend", "delivery", "Implements UI flows.", 300},
|
||||
{"reviewer", "Reviewer", "review", "Gates merge readiness.", 400},
|
||||
{"discovery_ux", "Discovery UX", "discovery", "Finds UX opportunities.", 500},
|
||||
{"discovery_quality", "Discovery Quality", "discovery", "Finds quality gaps.", 600},
|
||||
{"discovery_growth", "Discovery Growth", "discovery", "Finds growth opportunities.", 700},
|
||||
}
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
for _, item := range legacyRoles {
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO roles(name, title, category, executor_kind, description, is_enabled, is_builtin, sort_order, created_at, updated_at)
|
||||
VALUES(?, ?, ?, 'codex', ?, 1, 1, ?, ?, ?)
|
||||
`, item.name, item.title, item.category, item.description, item.sortOrder, now, now); err != nil {
|
||||
t.Fatalf("insert legacy role %s: %v", item.name, err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO role_prompts(id, role_name, workspace_id, prompt_kind, content_markdown, version, updated_by, created_at, updated_at)
|
||||
VALUES(?, ?, NULL, 'system', ?, 1, 'legacy-seed', ?, ?)
|
||||
`, "legacy-role-prompt-"+item.name, item.name, "legacy prompt for "+item.name, now, now); err != nil {
|
||||
t.Fatalf("insert legacy role prompt %s: %v", item.name, err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO role_configs(id, role_name, workspace_id, model, model_provider, provider_base_url, provider_wire_api, reasoning_effort, shell_env_inherit, shell_env_overrides_json, extra_config_json, version, updated_by, created_at, updated_at)
|
||||
VALUES(?, ?, NULL, 'gpt-5', '', '', '', '', 'core', '{}', ?, 1, 'legacy-seed', ?, ?)
|
||||
`, "legacy-role-config-"+item.name, item.name, `{"auth_json":{"OPENAI_API_KEY":"token-`+item.name+`"}}`, now, now); err != nil {
|
||||
t.Fatalf("insert legacy role config %s: %v", item.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, db, clock); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
if err := store.ensureBuiltinRoles(ctx); err != nil {
|
||||
t.Fatalf("ensureBuiltinRoles() after migrate error = %v", err)
|
||||
}
|
||||
if err := store.ensureBuiltinSkills(ctx); err != nil {
|
||||
t.Fatalf("ensureBuiltinSkills() after migrate error = %v", err)
|
||||
}
|
||||
|
||||
roles, err := store.ListRoles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRoles() error = %v", err)
|
||||
}
|
||||
if len(roles) != 3 {
|
||||
t.Fatalf("expected 3 builtin roles after migration cleanup, got %d", len(roles))
|
||||
}
|
||||
for index, expected := range []string{"user", "leader", "worker"} {
|
||||
if roles[index].Name != expected {
|
||||
t.Fatalf("expected role[%d] = %s, got %s", index, expected, roles[index].Name)
|
||||
}
|
||||
}
|
||||
|
||||
var remainingLegacy int
|
||||
if err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM roles
|
||||
WHERE name IN ('product', 'backend', 'frontend', 'reviewer', 'discovery_ux', 'discovery_quality', 'discovery_growth')
|
||||
`).Scan(&remainingLegacy); err != nil {
|
||||
t.Fatalf("count remaining legacy roles: %v", err)
|
||||
}
|
||||
if remainingLegacy != 0 {
|
||||
t.Fatalf("expected legacy roles to be removed, got %d", remainingLegacy)
|
||||
}
|
||||
|
||||
for _, table := range []string{"role_prompts", "role_skill_bindings"} {
|
||||
var count int
|
||||
query := `
|
||||
SELECT COUNT(*) FROM ` + table + `
|
||||
WHERE role_name IN ('product', 'backend', 'frontend', 'reviewer', 'discovery_ux', 'discovery_quality', 'discovery_growth')
|
||||
`
|
||||
if err := db.QueryRow(query).Scan(&count); err != nil {
|
||||
t.Fatalf("count %s legacy rows: %v", table, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no legacy rows in %s after migration, got %d", table, count)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`SELECT COUNT(*) FROM role_configs`); err == nil {
|
||||
t.Fatalf("expected role_configs table to be dropped after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertConfigWritesAuditLog(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := OpenInMemory(timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.UpsertRole(ctx, role.Definition{
|
||||
Name: "planner",
|
||||
Title: "Planner",
|
||||
IsEnabled: true,
|
||||
IsBuiltin: true,
|
||||
}, "seed"); err != nil {
|
||||
t.Fatalf("UpsertRole() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.UpsertRolePrompt(ctx, role.Prompt{
|
||||
RoleName: "planner",
|
||||
PromptKind: role.PromptSystem,
|
||||
ContentMarkdown: "System prompt",
|
||||
}, "tester"); err != nil {
|
||||
t.Fatalf("UpsertRolePrompt() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.UpsertRoleConfig(ctx, role.Config{
|
||||
RoleName: "planner",
|
||||
ConfigTOML: "model = \"gpt-5.4\"",
|
||||
AuthJSON: "{\"OPENAI_API_KEY\":\"token-1\"}",
|
||||
}, "tester"); err != nil {
|
||||
t.Fatalf("UpsertRoleConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name = 'config_change_logs'`).Scan(&count); err != nil {
|
||||
t.Fatalf("check dropped table config_change_logs: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected config_change_logs to remain dropped after upserts, got count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolePromptGlobalUniqueness(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := OpenInMemory(timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.UpsertRole(ctx, role.Definition{
|
||||
Name: "builder",
|
||||
Title: "Builder",
|
||||
IsEnabled: true,
|
||||
IsBuiltin: true,
|
||||
}, "seed"); err != nil {
|
||||
t.Fatalf("UpsertRole() error = %v", err)
|
||||
}
|
||||
|
||||
first, err := store.UpsertRolePrompt(ctx, role.Prompt{
|
||||
RoleName: "builder",
|
||||
PromptKind: role.PromptSystem,
|
||||
ContentMarkdown: "v1",
|
||||
}, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("first UpsertRolePrompt() error = %v", err)
|
||||
}
|
||||
|
||||
second, err := store.UpsertRolePrompt(ctx, role.Prompt{
|
||||
RoleName: "builder",
|
||||
PromptKind: role.PromptSystem,
|
||||
ContentMarkdown: "v2",
|
||||
}, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("second UpsertRolePrompt() error = %v", err)
|
||||
}
|
||||
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("expected same prompt row to be updated, got %q and %q", first.ID, second.ID)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM role_prompts WHERE role_name = ? AND prompt_kind = ?`, "builder", string(role.PromptSystem)).Scan(&count); err != nil {
|
||||
t.Fatalf("count role_prompts: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected exactly 1 prompt row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedConfigUpsertsReuseRowsAndAdvanceVersion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := OpenInMemory(timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
firstSkill, err := store.UpsertSkill(ctx, skill.Definition{
|
||||
SkillKey: "planner",
|
||||
Name: "Planner",
|
||||
Description: "Plan work",
|
||||
ContentMarkdown: "v1",
|
||||
Status: "active",
|
||||
}, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("first UpsertSkill() error = %v", err)
|
||||
}
|
||||
secondSkill, err := store.UpsertSkill(ctx, skill.Definition{
|
||||
SkillKey: "planner",
|
||||
Name: "Planner v2",
|
||||
Description: "Plan work better",
|
||||
ContentMarkdown: "v2",
|
||||
Status: "active",
|
||||
}, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("second UpsertSkill() error = %v", err)
|
||||
}
|
||||
if firstSkill.ID != secondSkill.ID {
|
||||
t.Fatalf("expected skill row to be updated in place, got %q and %q", firstSkill.ID, secondSkill.ID)
|
||||
}
|
||||
if firstSkill.Version != 1 || secondSkill.Version != 2 {
|
||||
t.Fatalf("expected skill versions 1 -> 2, got %d -> %d", firstSkill.Version, secondSkill.Version)
|
||||
}
|
||||
if firstSkill.CreatedAt != secondSkill.CreatedAt {
|
||||
t.Fatalf("expected skill created_at to be preserved, got %q and %q", firstSkill.CreatedAt, secondSkill.CreatedAt)
|
||||
}
|
||||
|
||||
firstBinding, err := store.UpsertRoleSkillBinding(ctx, role.SkillBinding{
|
||||
RoleName: "leader",
|
||||
SkillID: secondSkill.ID,
|
||||
IsEnabled: true,
|
||||
SortOrder: 10,
|
||||
Config: map[string]any{
|
||||
"mode": "strict",
|
||||
},
|
||||
}, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("first UpsertRoleSkillBinding() error = %v", err)
|
||||
}
|
||||
secondBinding, err := store.UpsertRoleSkillBinding(ctx, role.SkillBinding{
|
||||
RoleName: "leader",
|
||||
SkillID: secondSkill.ID,
|
||||
IsEnabled: false,
|
||||
SortOrder: 20,
|
||||
Config: map[string]any{
|
||||
"mode": "relaxed",
|
||||
},
|
||||
}, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("second UpsertRoleSkillBinding() error = %v", err)
|
||||
}
|
||||
if firstBinding.ID != secondBinding.ID {
|
||||
t.Fatalf("expected role skill binding row to be updated in place, got %q and %q", firstBinding.ID, secondBinding.ID)
|
||||
}
|
||||
if firstBinding.Version != 1 || secondBinding.Version != 2 {
|
||||
t.Fatalf("expected role skill binding versions 1 -> 2, got %d -> %d", firstBinding.Version, secondBinding.Version)
|
||||
}
|
||||
if firstBinding.CreatedAt != secondBinding.CreatedAt {
|
||||
t.Fatalf("expected role skill binding created_at to be preserved, got %q and %q", firstBinding.CreatedAt, secondBinding.CreatedAt)
|
||||
}
|
||||
|
||||
var skillCount int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM skills WHERE skill_key = ?`, "planner").Scan(&skillCount); err != nil {
|
||||
t.Fatalf("count skills: %v", err)
|
||||
}
|
||||
if skillCount != 1 {
|
||||
t.Fatalf("expected exactly 1 skill row, got %d", skillCount)
|
||||
}
|
||||
|
||||
var bindingCount int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM role_skill_bindings WHERE role_name = ? AND skill_id = ?`, "leader", secondSkill.ID).Scan(&bindingCount); err != nil {
|
||||
t.Fatalf("count role_skill_bindings: %v", err)
|
||||
}
|
||||
if bindingCount != 1 {
|
||||
t.Fatalf("expected exactly 1 role skill binding row, got %d", bindingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRoleConfigBoolCompatibility(t *testing.T) {
|
||||
store, err := OpenInMemory(timeutil.FixedClock{Time: time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
row := store.DB().QueryRow(`SELECT 1`)
|
||||
var value sql.NullInt64
|
||||
if err := row.Scan(&value); err != nil {
|
||||
t.Fatalf("scan int compatibility: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/message"
|
||||
)
|
||||
|
||||
func (s *Store) CreateHumanTask(ctx context.Context, value humantask.Record) (humantask.Record, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return humantask.Record{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return humantask.Record{}, fmt.Errorf("begin create human task: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
item, err := insertHumanTaskTx(ctx, tx, s, value)
|
||||
if err != nil {
|
||||
return humantask.Record{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return humantask.Record{}, fmt.Errorf("commit create human task: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func insertHumanTaskTx(ctx context.Context, tx *sql.Tx, s *Store, value humantask.Record) (humantask.Record, error) {
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("human-task")
|
||||
if err != nil {
|
||||
return humantask.Record{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
value.Status = humantask.Status(strings.TrimSpace(string(value.Status)))
|
||||
if value.CreatedAt == "" {
|
||||
value.CreatedAt = s.now()
|
||||
}
|
||||
if value.UpdatedAt == "" {
|
||||
value.UpdatedAt = value.CreatedAt
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return humantask.Record{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO human_tasks(id, workspace_id, topic_id, role_name, prompt_message_id, status, answered_message_id, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
value.ID,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.RoleName,
|
||||
value.PromptMessageID,
|
||||
string(value.Status),
|
||||
nullableString(value.AnsweredMessageID),
|
||||
value.CreatedAt,
|
||||
value.UpdatedAt,
|
||||
); err != nil {
|
||||
return humantask.Record{}, fmt.Errorf("insert human task: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetHumanTask(ctx context.Context, taskID string) (humantask.Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, prompt_message_id, status, answered_message_id, created_at, updated_at
|
||||
FROM human_tasks
|
||||
WHERE id = ?
|
||||
`, taskID)
|
||||
return scanHumanTask(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateHumanTask(ctx context.Context, value humantask.Record) (humantask.Record, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return humantask.Record{}, err
|
||||
}
|
||||
if value.UpdatedAt == "" {
|
||||
value.UpdatedAt = s.now()
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE human_tasks
|
||||
SET status = ?, answered_message_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
string(value.Status),
|
||||
nullableString(value.AnsweredMessageID),
|
||||
value.UpdatedAt,
|
||||
value.ID,
|
||||
); err != nil {
|
||||
return humantask.Record{}, fmt.Errorf("update human task: %w", err)
|
||||
}
|
||||
return s.GetHumanTask(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (s *Store) AnswerHumanTask(ctx context.Context, taskID string, reply message.Record) (humantask.Record, message.Record, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return humantask.Record{}, message.Record{}, fmt.Errorf("begin answer human task: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
task, err := getHumanTaskTx(ctx, tx, taskID)
|
||||
if err != nil {
|
||||
return humantask.Record{}, message.Record{}, err
|
||||
}
|
||||
if task.Status != humantask.StatusPending {
|
||||
return humantask.Record{}, message.Record{}, fmt.Errorf("human task %s is not pending", task.ID)
|
||||
}
|
||||
|
||||
savedReply, err := s.createMessageTx(ctx, tx, reply)
|
||||
if err != nil {
|
||||
return humantask.Record{}, message.Record{}, err
|
||||
}
|
||||
|
||||
task.Status = humantask.StatusAnswered
|
||||
task.AnsweredMessageID = savedReply.ID
|
||||
task.UpdatedAt = coalesceString(reply.CreatedAt, s.now())
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE human_tasks
|
||||
SET status = ?, answered_message_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, string(task.Status), nullableString(task.AnsweredMessageID), task.UpdatedAt, task.ID); err != nil {
|
||||
return humantask.Record{}, message.Record{}, fmt.Errorf("update answered human task: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return humantask.Record{}, message.Record{}, fmt.Errorf("commit answer human task: %w", err)
|
||||
}
|
||||
|
||||
updated, err := s.GetHumanTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return humantask.Record{}, message.Record{}, err
|
||||
}
|
||||
return updated, savedReply, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListHumanTasksByTopic(ctx context.Context, topicID string) ([]humantask.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, prompt_message_id, status, answered_message_id, created_at, updated_at
|
||||
FROM human_tasks
|
||||
WHERE topic_id = ?
|
||||
ORDER BY created_at, id
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list human tasks by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []humantask.Record
|
||||
for rows.Next() {
|
||||
item, err := scanHumanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate human tasks by topic: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListPendingHumanTasksByWorkspace(ctx context.Context, workspaceID string) ([]humantask.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, prompt_message_id, status, answered_message_id, created_at, updated_at
|
||||
FROM human_tasks
|
||||
WHERE workspace_id = ? AND status = ?
|
||||
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||
`, workspaceID, string(humantask.StatusPending))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending human tasks by workspace: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []humantask.Record
|
||||
for rows.Next() {
|
||||
item, err := scanHumanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pending human tasks by workspace: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanHumanTask(s scanner) (humantask.Record, error) {
|
||||
var item humantask.Record
|
||||
var status string
|
||||
var answeredMessageID sql.NullString
|
||||
if err := s.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceID,
|
||||
&item.TopicID,
|
||||
&item.RoleName,
|
||||
&item.PromptMessageID,
|
||||
&status,
|
||||
&answeredMessageID,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return humantask.Record{}, err
|
||||
}
|
||||
item.Status = humantask.Status(status)
|
||||
if answeredMessageID.Valid {
|
||||
item.AnsweredMessageID = answeredMessageID.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func getHumanTaskTx(ctx context.Context, tx *sql.Tx, taskID string) (humantask.Record, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, prompt_message_id, status, answered_message_id, created_at, updated_at
|
||||
FROM human_tasks
|
||||
WHERE id = ?
|
||||
`, taskID)
|
||||
return scanHumanTask(row)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/lanesync"
|
||||
)
|
||||
|
||||
func (s *Store) CreateLaneSync(ctx context.Context, value lanesync.Record) (lanesync.Record, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return lanesync.Record{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("lane-sync")
|
||||
if err != nil {
|
||||
return lanesync.Record{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
now := s.now()
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, now)
|
||||
value.UpdatedAt = now
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO lane_syncs(
|
||||
id, workspace_id, topic_id, downstream_lane_id, upstream_lane_id, task_id, upstream_commit,
|
||||
merge_commit, status, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
value.ID,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.DownstreamLaneID,
|
||||
value.UpstreamLaneID,
|
||||
value.TaskID,
|
||||
value.UpstreamCommit,
|
||||
value.MergeCommit,
|
||||
string(value.Status),
|
||||
value.ErrorMessage,
|
||||
value.CreatedAt,
|
||||
value.UpdatedAt,
|
||||
); err != nil {
|
||||
return lanesync.Record{}, fmt.Errorf("create lane sync: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLaneSyncsByTopic(ctx context.Context, topicID string) ([]lanesync.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, downstream_lane_id, upstream_lane_id, task_id, upstream_commit,
|
||||
merge_commit, status, error_message, created_at, updated_at
|
||||
FROM lane_syncs
|
||||
WHERE topic_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lane syncs by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]lanesync.Record, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanLaneSync(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate lane syncs by topic: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanLaneSync(s scanner) (lanesync.Record, error) {
|
||||
var item lanesync.Record
|
||||
var status string
|
||||
if err := s.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceID,
|
||||
&item.TopicID,
|
||||
&item.DownstreamLaneID,
|
||||
&item.UpstreamLaneID,
|
||||
&item.TaskID,
|
||||
&item.UpstreamCommit,
|
||||
&item.MergeCommit,
|
||||
&status,
|
||||
&item.ErrorMessage,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return lanesync.Record{}, err
|
||||
}
|
||||
item.Status = lanesync.Status(status)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
var _ scanner = (*sql.Row)(nil)
|
||||
@@ -0,0 +1,209 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/lane"
|
||||
)
|
||||
|
||||
func (s *Store) CreateLane(ctx context.Context, value lane.Record) (lane.Record, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("lane")
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
now := s.now()
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, now)
|
||||
value.UpdatedAt = now
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO lanes(
|
||||
id, workspace_id, topic_id, name, slug, purpose, status, base_branch, branch_name, head_commit, worktree_path,
|
||||
container_name, runtime_endpoint, created_by_role_name, result_summary_markdown, error_message,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
value.ID,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.Name,
|
||||
value.Slug,
|
||||
value.Purpose,
|
||||
string(value.Status),
|
||||
value.BaseBranch,
|
||||
value.BranchName,
|
||||
value.HeadCommit,
|
||||
value.WorktreePath,
|
||||
value.ContainerName,
|
||||
value.RuntimeEndpoint,
|
||||
value.CreatedByRoleName,
|
||||
value.ResultSummaryMarkdown,
|
||||
value.ErrorMessage,
|
||||
value.CreatedAt,
|
||||
value.UpdatedAt,
|
||||
nullableString(value.StartedAt),
|
||||
nullableString(value.CompletedAt),
|
||||
); err != nil {
|
||||
return lane.Record{}, fmt.Errorf("create lane: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetLane(ctx context.Context, laneID string) (lane.Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, name, slug, purpose, status, base_branch, branch_name, head_commit, worktree_path,
|
||||
container_name, runtime_endpoint, created_by_role_name, result_summary_markdown, error_message,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM lanes
|
||||
WHERE id = ?
|
||||
`, laneID)
|
||||
return scanLane(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListLanesByTopic(ctx context.Context, topicID string) ([]lane.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, name, slug, purpose, status, base_branch, branch_name, head_commit, worktree_path,
|
||||
container_name, runtime_endpoint, created_by_role_name, result_summary_markdown, error_message,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM lanes
|
||||
WHERE topic_id = ?
|
||||
ORDER BY created_at, id
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lanes by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []lane.Record
|
||||
for rows.Next() {
|
||||
item, err := scanLane(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate lanes by topic: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLanesByWorkspace(ctx context.Context, workspaceID string) ([]lane.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, name, slug, purpose, status, base_branch, branch_name, head_commit, worktree_path,
|
||||
container_name, runtime_endpoint, created_by_role_name, result_summary_markdown, error_message,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM lanes
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY created_at, id
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lanes by workspace: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []lane.Record
|
||||
for rows.Next() {
|
||||
item, err := scanLane(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate lanes by workspace: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateLane(ctx context.Context, value lane.Record) (lane.Record, error) {
|
||||
if value.ID == "" {
|
||||
return lane.Record{}, fmt.Errorf("lane id is required")
|
||||
}
|
||||
before, err := s.GetLane(ctx, value.ID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
value.CreatedAt = before.CreatedAt
|
||||
value.UpdatedAt = s.now()
|
||||
if err := value.Validate(); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE lanes
|
||||
SET workspace_id = ?, topic_id = ?, name = ?, slug = ?, purpose = ?, status = ?, base_branch = ?, branch_name = ?, head_commit = ?,
|
||||
worktree_path = ?, container_name = ?, runtime_endpoint = ?, created_by_role_name = ?,
|
||||
result_summary_markdown = ?, error_message = ?, updated_at = ?, started_at = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.Name,
|
||||
value.Slug,
|
||||
value.Purpose,
|
||||
string(value.Status),
|
||||
value.BaseBranch,
|
||||
value.BranchName,
|
||||
value.HeadCommit,
|
||||
value.WorktreePath,
|
||||
value.ContainerName,
|
||||
value.RuntimeEndpoint,
|
||||
value.CreatedByRoleName,
|
||||
value.ResultSummaryMarkdown,
|
||||
value.ErrorMessage,
|
||||
value.UpdatedAt,
|
||||
nullableString(value.StartedAt),
|
||||
nullableString(value.CompletedAt),
|
||||
value.ID,
|
||||
); err != nil {
|
||||
return lane.Record{}, fmt.Errorf("update lane: %w", err)
|
||||
}
|
||||
return s.GetLane(ctx, value.ID)
|
||||
}
|
||||
|
||||
func scanLane(s scanner) (lane.Record, error) {
|
||||
var item lane.Record
|
||||
var status string
|
||||
var startedAt sql.NullString
|
||||
var completedAt sql.NullString
|
||||
if err := s.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceID,
|
||||
&item.TopicID,
|
||||
&item.Name,
|
||||
&item.Slug,
|
||||
&item.Purpose,
|
||||
&status,
|
||||
&item.BaseBranch,
|
||||
&item.BranchName,
|
||||
&item.HeadCommit,
|
||||
&item.WorktreePath,
|
||||
&item.ContainerName,
|
||||
&item.RuntimeEndpoint,
|
||||
&item.CreatedByRoleName,
|
||||
&item.ResultSummaryMarkdown,
|
||||
&item.ErrorMessage,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&startedAt,
|
||||
&completedAt,
|
||||
); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
item.Status = lane.Status(status)
|
||||
if startedAt.Valid {
|
||||
item.StartedAt = startedAt.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
item.CompletedAt = completedAt.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/humantask"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
)
|
||||
|
||||
func (s *Store) CreateMessage(ctx context.Context, value message.Record) (message.Record, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return message.Record{}, fmt.Errorf("begin create message: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
item, err := s.createMessageTx(ctx, tx, value)
|
||||
if err != nil {
|
||||
return message.Record{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return message.Record{}, fmt.Errorf("commit create message: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListMessagesByTopic(ctx context.Context, topicID string) ([]message.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, from_role_name, to_expr, type, stage, reply_to_message_id, body_markdown, created_at
|
||||
FROM messages
|
||||
WHERE topic_id = ?
|
||||
ORDER BY created_at, id
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []message.Record
|
||||
for rows.Next() {
|
||||
item, err := scanMessage(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate messages: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListMessagesByWorkspace(ctx context.Context, workspaceID string) ([]message.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, from_role_name, to_expr, type, stage, reply_to_message_id, body_markdown, created_at
|
||||
FROM messages
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY created_at, id
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list messages by workspace: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []message.Record
|
||||
for rows.Next() {
|
||||
item, err := scanMessage(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate messages by workspace: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListPendingDeliveriesByWorkspace(ctx context.Context, workspaceID string) ([]message.PendingDelivery, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT m.topic_id, d.recipient_role_name, COUNT(*), MAX(d.updated_at)
|
||||
FROM message_deliveries d
|
||||
JOIN messages m ON m.id = d.message_id
|
||||
JOIN roles r ON r.name = d.recipient_role_name
|
||||
WHERE m.workspace_id = ? AND d.state = ? AND r.executor_kind = ?
|
||||
GROUP BY m.topic_id, d.recipient_role_name
|
||||
ORDER BY MAX(d.updated_at) DESC, m.topic_id, d.recipient_role_name
|
||||
`, workspaceID, string(message.DeliveryPending), string(role.ExecutorKindCodex))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending deliveries by workspace: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []message.PendingDelivery
|
||||
for rows.Next() {
|
||||
var item message.PendingDelivery
|
||||
if err := rows.Scan(&item.TopicID, &item.RoleName, &item.Count, &item.LastUpdated); err != nil {
|
||||
return nil, fmt.Errorf("scan pending delivery: %w", err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate pending deliveries: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) expandRecipients(ctx context.Context, tx *sql.Tx, toExpr, fromRole string) ([]string, error) {
|
||||
toExpr = strings.TrimSpace(toExpr)
|
||||
if toExpr == "" {
|
||||
return nil, fmt.Errorf("to expr is required")
|
||||
}
|
||||
if toExpr == "all" {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT name FROM roles WHERE is_enabled = 1 ORDER BY sort_order, name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list recipient roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("scan recipient role: %w", err)
|
||||
}
|
||||
if name == fromRole {
|
||||
continue
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate recipient roles: %w", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no recipients resolved for %q", toExpr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(toExpr, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
name := strings.TrimSpace(part)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no recipients resolved for %q", toExpr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) createMessageTx(ctx context.Context, tx *sql.Tx, value message.Record) (message.Record, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return message.Record{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("message")
|
||||
if err != nil {
|
||||
return message.Record{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, s.now())
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO messages(id, workspace_id, topic_id, from_role_name, to_expr, type, stage, reply_to_message_id, body_markdown, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, value.ID, value.WorkspaceID, value.TopicID, value.FromRoleName, value.ToExpr, string(value.Type), value.Stage, nullableString(value.ReplyToMessageID), value.BodyMarkdown, value.CreatedAt); err != nil {
|
||||
return message.Record{}, fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
|
||||
recipients, err := s.expandRecipients(ctx, tx, value.ToExpr, value.FromRoleName)
|
||||
if err != nil {
|
||||
return message.Record{}, err
|
||||
}
|
||||
recipientRoles, err := resolveRecipientRolesTx(ctx, tx, recipients)
|
||||
if err != nil {
|
||||
return message.Record{}, err
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
definition, ok := recipientRoles[recipient]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch definition.ExecutorKind {
|
||||
case role.ExecutorKindHuman:
|
||||
if value.Type != message.TypeQuestion {
|
||||
continue
|
||||
}
|
||||
if _, err := insertHumanTaskTx(ctx, tx, s, humantask.Record{
|
||||
WorkspaceID: value.WorkspaceID,
|
||||
TopicID: value.TopicID,
|
||||
RoleName: recipient,
|
||||
PromptMessageID: value.ID,
|
||||
Status: humantask.StatusPending,
|
||||
CreatedAt: value.CreatedAt,
|
||||
UpdatedAt: value.CreatedAt,
|
||||
}); err != nil {
|
||||
return message.Record{}, err
|
||||
}
|
||||
default:
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO message_deliveries(message_id, recipient_role_name, state, delivered_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
`, value.ID, recipient, string(message.DeliveryPending), value.CreatedAt, value.CreatedAt); err != nil {
|
||||
return message.Record{}, fmt.Errorf("insert message delivery: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func resolveRecipientRolesTx(ctx context.Context, tx *sql.Tx, recipients []string) (map[string]role.Definition, error) {
|
||||
if len(recipients) == 0 {
|
||||
return map[string]role.Definition{}, nil
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT name, title, executor_kind, description, is_enabled, is_builtin, sort_order, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE name IN (`+placeholders(len(recipients))+`)
|
||||
`, stringSliceToAny(recipients)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list recipient role executors: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]role.Definition, len(recipients))
|
||||
for rows.Next() {
|
||||
item, err := scanRole(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[item.Name] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate recipient role executors: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func stringSliceToAny(values []string) []any {
|
||||
out := make([]any, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanMessage(s scanner) (message.Record, error) {
|
||||
var item message.Record
|
||||
var msgType string
|
||||
var replyTo sql.NullString
|
||||
if err := s.Scan(&item.ID, &item.WorkspaceID, &item.TopicID, &item.FromRoleName, &item.ToExpr, &msgType, &item.Stage, &replyTo, &item.BodyMarkdown, &item.CreatedAt); err != nil {
|
||||
return message.Record{}, err
|
||||
}
|
||||
item.Type = message.Type(msgType)
|
||||
if replyTo.Valid {
|
||||
item.ReplyToMessageID = replyTo.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
func TestCreateMessageCreatesHumanTaskOnlyForQuestionMessages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 17, 15, 0, 0, 0, time.UTC)}
|
||||
store, err := OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
project, err := store.CreateProject(ctx, workspace.Project{
|
||||
Slug: "demo",
|
||||
Name: "Demo",
|
||||
RootPath: t.TempDir(),
|
||||
DefaultBranch: "main",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "demo",
|
||||
Name: "demo",
|
||||
RootPath: t.TempDir(),
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/demo",
|
||||
RuntimeBackend: "host",
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
if _, err := store.UpsertRole(ctx, role.Definition{
|
||||
Name: "approver",
|
||||
Title: "Approver",
|
||||
ExecutorKind: role.ExecutorKindHuman,
|
||||
IsEnabled: true,
|
||||
}, "test"); err != nil {
|
||||
t.Fatalf("UpsertRole(approver) error = %v", err)
|
||||
}
|
||||
topicRecord, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
Slug: "sample",
|
||||
Title: "sample",
|
||||
Space: "workflow",
|
||||
Status: "plan",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic() error = %v", err)
|
||||
}
|
||||
|
||||
decision, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "leader",
|
||||
ToExpr: "approver",
|
||||
Type: message.TypeDecision,
|
||||
Stage: "execution",
|
||||
BodyMarkdown: "Proceed with the rollout.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessage(decision) error = %v", err)
|
||||
}
|
||||
question, err := store.CreateMessage(ctx, message.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
FromRoleName: "leader",
|
||||
ToExpr: "approver",
|
||||
Type: message.TypeQuestion,
|
||||
Stage: "review",
|
||||
BodyMarkdown: "Should this ship tonight?",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMessage(question) error = %v", err)
|
||||
}
|
||||
|
||||
var humanTaskCount int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM human_tasks WHERE topic_id = ?`, topicRecord.ID).Scan(&humanTaskCount); err != nil {
|
||||
t.Fatalf("count human_tasks: %v", err)
|
||||
}
|
||||
if humanTaskCount != 1 {
|
||||
t.Fatalf("expected exactly one human task, got %d", humanTaskCount)
|
||||
}
|
||||
|
||||
var promptMessageID string
|
||||
if err := store.DB().QueryRow(`SELECT prompt_message_id FROM human_tasks WHERE topic_id = ?`, topicRecord.ID).Scan(&promptMessageID); err != nil {
|
||||
t.Fatalf("select prompt_message_id: %v", err)
|
||||
}
|
||||
if promptMessageID != question.ID {
|
||||
t.Fatalf("expected human task to point at question message %s, got %s", question.ID, promptMessageID)
|
||||
}
|
||||
|
||||
var decisionHumanTaskCount int
|
||||
if err := store.DB().QueryRow(`SELECT COUNT(*) FROM human_tasks WHERE prompt_message_id = ?`, decision.ID).Scan(&decisionHumanTaskCount); err != nil {
|
||||
t.Fatalf("count decision human task: %v", err)
|
||||
}
|
||||
if decisionHumanTaskCount != 0 {
|
||||
t.Fatalf("expected no human task for decision message, got %d", decisionHumanTaskCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL UNIQUE,
|
||||
default_branch TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL UNIQUE,
|
||||
base_branch TEXT NOT NULL,
|
||||
worktree_branch TEXT NOT NULL UNIQUE,
|
||||
runtime_backend TEXT NOT NULL,
|
||||
runtime_ref TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_project ON workspaces(project_id, status, slug);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
name TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_builtin INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_prompts (
|
||||
id TEXT PRIMARY KEY,
|
||||
role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
prompt_kind TEXT NOT NULL,
|
||||
content_markdown TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_prompts_global
|
||||
ON role_prompts(role_name, prompt_kind)
|
||||
WHERE workspace_id IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_prompts_workspace
|
||||
ON role_prompts(role_name, workspace_id, prompt_kind)
|
||||
WHERE workspace_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_role_prompts_role ON role_prompts(role_name, prompt_kind, workspace_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
model_provider TEXT NOT NULL DEFAULT '',
|
||||
provider_name TEXT NOT NULL DEFAULT '',
|
||||
provider_base_url TEXT NOT NULL DEFAULT '',
|
||||
provider_wire_api TEXT NOT NULL DEFAULT '',
|
||||
reasoning_effort TEXT NOT NULL DEFAULT '',
|
||||
plan_model TEXT NOT NULL DEFAULT '',
|
||||
plan_reasoning_effort TEXT NOT NULL DEFAULT '',
|
||||
disable_response_storage INTEGER NOT NULL DEFAULT 0,
|
||||
shell_env_inherit TEXT NOT NULL DEFAULT 'core',
|
||||
shell_env_overrides_json TEXT NOT NULL DEFAULT '{}',
|
||||
extra_config_json TEXT NOT NULL DEFAULT '{}',
|
||||
version INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_configs_global
|
||||
ON role_configs(role_name)
|
||||
WHERE workspace_id IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_configs_workspace
|
||||
ON role_configs(role_name, workspace_id)
|
||||
WHERE workspace_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_role_configs_role ON role_configs(role_name, workspace_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
skill_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
source_type TEXT NOT NULL,
|
||||
content_markdown TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skills_status ON skills(status, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_skill_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
version INTEGER NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_skill_bindings_global
|
||||
ON role_skill_bindings(role_name, skill_id)
|
||||
WHERE workspace_id IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_role_skill_bindings_workspace
|
||||
ON role_skill_bindings(role_name, workspace_id, skill_id)
|
||||
WHERE workspace_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_role_skill_bindings_role ON role_skill_bindings(role_name, workspace_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_change_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
config_domain TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
change_action TEXT NOT NULL,
|
||||
before_json TEXT NOT NULL DEFAULT '{}',
|
||||
after_json TEXT NOT NULL DEFAULT '{}',
|
||||
changed_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_change_logs_domain ON config_change_logs(config_domain, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_change_logs_workspace ON config_change_logs(workspace_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
space TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
source_topic_id TEXT REFERENCES topics(id) ON DELETE SET NULL,
|
||||
owner_role_name TEXT REFERENCES roles(name) ON DELETE SET NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
meta_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
closed_at TEXT,
|
||||
UNIQUE(workspace_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_workspace_space ON topics(workspace_id, space, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topic_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
content_markdown TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
updated_by_role_name TEXT REFERENCES roles(name) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(topic_id, kind)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
from_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
to_expr TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
round INTEGER,
|
||||
reply_to_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
body_markdown TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_workspace_topic ON messages(workspace_id, topic_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_reply_to ON messages(reply_to_message_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_deliveries (
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
recipient_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
state TEXT NOT NULL,
|
||||
delivered_at TEXT NOT NULL,
|
||||
read_at TEXT,
|
||||
archived_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, recipient_role_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_deliveries_role ON message_deliveries(recipient_role_name, state, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS role_threads (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
thread_id TEXT NOT NULL,
|
||||
last_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, topic_id, role_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_role_threads_role ON role_threads(role_name, last_used_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS requirements (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
body_markdown TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
created_by_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_requirements_workspace_status ON requirements(workspace_id, status, priority, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_requirements_topic ON requirements(topic_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discovery_rounds (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
phase TEXT NOT NULL,
|
||||
request_document_id TEXT REFERENCES topic_documents(id) ON DELETE SET NULL,
|
||||
result_document_id TEXT REFERENCES topic_documents(id) ON DELETE SET NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_rounds_topic ON discovery_rounds(topic_id, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discovery_candidates (
|
||||
id TEXT PRIMARY KEY,
|
||||
round_id TEXT NOT NULL REFERENCES discovery_rounds(id) ON DELETE CASCADE,
|
||||
proposer_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
status TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
problem TEXT NOT NULL DEFAULT '',
|
||||
evidence TEXT NOT NULL DEFAULT '',
|
||||
proposal TEXT NOT NULL DEFAULT '',
|
||||
expected_impact TEXT NOT NULL DEFAULT '',
|
||||
risk TEXT NOT NULL DEFAULT '',
|
||||
how_to_verify TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_candidates_round ON discovery_candidates(round_id, status, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discovery_votes (
|
||||
id TEXT PRIMARY KEY,
|
||||
round_id TEXT NOT NULL REFERENCES discovery_rounds(id) ON DELETE CASCADE,
|
||||
candidate_id TEXT NOT NULL REFERENCES discovery_candidates(id) ON DELETE CASCADE,
|
||||
voter_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
vote TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_discovery_votes_unique ON discovery_votes(candidate_id, voter_role_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_votes_round ON discovery_votes(round_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
stage TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
request_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
thread_id TEXT NOT NULL DEFAULT '',
|
||||
prior_thread_id TEXT NOT NULL DEFAULT '',
|
||||
config_snapshot_json TEXT NOT NULL DEFAULT '{}',
|
||||
command_json TEXT NOT NULL DEFAULT '[]',
|
||||
reply_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
exit_code INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
error_message TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_topic ON workflow_runs(topic_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_role ON workflow_runs(role_name, status, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_run_logs (
|
||||
run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
stream TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (run_id, seq)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS merge_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
||||
requested_by_role_name TEXT REFERENCES roles(name) ON DELETE SET NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
files_changed INTEGER NOT NULL DEFAULT 0,
|
||||
insertions INTEGER NOT NULL DEFAULT 0,
|
||||
deletions INTEGER NOT NULL DEFAULT 0,
|
||||
changed_files_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
merged_at TEXT,
|
||||
error_message TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_merge_requests_topic ON merge_requests(topic_id, created_at);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE workspaces ADD COLUMN provision_state TEXT NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE workspaces ADD COLUMN provision_error TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE workspaces ADD COLUMN runtime_endpoint TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE workspaces ADD COLUMN last_provisioned_at TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspaces ADD COLUMN agent_max_jobs INTEGER NOT NULL DEFAULT 5;
|
||||
@@ -0,0 +1,117 @@
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 product。
|
||||
|
||||
你负责当前主题的范围控制。把用户意图和 discovery 证据沉淀为 PRD 与决策日志,冻结要构建的内容,并持续消除交付阻塞。
|
||||
|
||||
规则:
|
||||
- 不要编写实现代码。
|
||||
- 所有决策都必须具体、可验证。
|
||||
- 保持范围收敛;优先交付更小但边界清晰的切片,不要放大成含糊的大范围。
|
||||
- 当 backend 或 frontend 进入执行阶段时,他们拿到的应该是冻结后的目标,而不是未决问题。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0004',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-product' AND updated_by = 'builtin-seed';
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 backend。
|
||||
|
||||
你只负责实现后端代码。遵循已经冻结的范围,保持行为一致,优先做小而可验证的改动。
|
||||
|
||||
规则:
|
||||
- 编写可投入生产的代码,不写计划稿。
|
||||
- 维护数据完整性和 API 清晰度。
|
||||
- 行为变化时补充或更新后端测试。
|
||||
- 只有在接口契约或冻结范围互相矛盾时才升级反馈。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0004',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-backend' AND updated_by = 'builtin-seed';
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 frontend。
|
||||
|
||||
你负责实现冻结范围内的用户界面。保持体验清晰、响应迅速,并与当前产品契约一致。
|
||||
|
||||
规则:
|
||||
- 编写真实可运行的 UI 代码,不要交付线框或伪代码。
|
||||
- 处理空状态、加载状态和错误状态。
|
||||
- 保证表单、文案和反馈状态可用。
|
||||
- 行为变化时补充或更新前端测试。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0004',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-frontend' AND updated_by = 'builtin-seed';
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 reviewer。
|
||||
|
||||
你要基于冻结范围和现有证据审查已交付的改动。你的职责是判断这项工作是否真的可以合并。
|
||||
|
||||
规则:
|
||||
- 除非为了完成验证只需要一个很小的修复,否则不要顺手开发新功能。
|
||||
- 对照 PRD、决策日志和验收标准检查实现。
|
||||
- 在批准前核查测试、变更文件以及面向用户的证明材料。
|
||||
- 当证据缺失或出现范围漂移时,要明确指出问题并退回修正。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0004',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-reviewer' AND updated_by = 'builtin-seed';
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 discovery_ux。
|
||||
|
||||
你需要从 UX 视角审查产品,并提出基于证据的需求候选。
|
||||
|
||||
重点关注:
|
||||
- 用户流程阻力
|
||||
- 容易引起误解的文案
|
||||
- 薄弱的空状态、加载状态和错误状态
|
||||
- 阻碍任务完成的交互缺口
|
||||
|
||||
每条提案都必须足够具体,便于 product 评估并冻结。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0004',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-discovery_ux' AND updated_by = 'builtin-seed';
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 discovery_quality。
|
||||
|
||||
你需要从质量与韧性视角审查产品,并提出基于证据的需求候选。
|
||||
|
||||
重点关注:
|
||||
- 错误处理缺口
|
||||
- 边界情况
|
||||
- 缺失的保护措施与兜底行为
|
||||
- 可测试性与回归风险
|
||||
|
||||
每条提案都必须明确说明具体问题,以及怎样才算可验证。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0004',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-discovery_quality' AND updated_by = 'builtin-seed';
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 discovery_growth。
|
||||
|
||||
你需要从增长视角审查产品,并提出基于证据的需求候选。
|
||||
|
||||
重点关注:
|
||||
- 价值表达
|
||||
- 可发现性
|
||||
- 激活阻力
|
||||
- 转化与留存信号
|
||||
|
||||
每条提案都必须描述面向用户的收益,而不只是实现思路。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0004',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-discovery_growth' AND updated_by = 'builtin-seed';
|
||||
@@ -0,0 +1,57 @@
|
||||
ALTER TABLE roles ADD COLUMN executor_kind TEXT NOT NULL DEFAULT 'codex';
|
||||
|
||||
UPDATE roles
|
||||
SET executor_kind = 'human'
|
||||
WHERE name = 'user';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS human_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
role_name TEXT NOT NULL REFERENCES roles(name) ON DELETE CASCADE,
|
||||
prompt_message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
answered_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(prompt_message_id, role_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_human_tasks_workspace_status ON human_tasks(workspace_id, status, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_human_tasks_topic_status ON human_tasks(topic_id, status, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_human_tasks_role_status ON human_tasks(role_name, status, updated_at);
|
||||
|
||||
INSERT INTO human_tasks(
|
||||
id,
|
||||
workspace_id,
|
||||
topic_id,
|
||||
role_name,
|
||||
prompt_message_id,
|
||||
status,
|
||||
answered_message_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
'human-task-' || d.message_id || '-' || d.recipient_role_name,
|
||||
m.workspace_id,
|
||||
m.topic_id,
|
||||
d.recipient_role_name,
|
||||
d.message_id,
|
||||
'pending',
|
||||
NULL,
|
||||
d.delivered_at,
|
||||
d.updated_at
|
||||
FROM message_deliveries d
|
||||
JOIN messages m ON m.id = d.message_id
|
||||
WHERE d.recipient_role_name = 'user'
|
||||
AND d.state IN ('pending', 'received', 'read')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM human_tasks ht
|
||||
WHERE ht.prompt_message_id = d.message_id
|
||||
AND ht.role_name = d.recipient_role_name
|
||||
);
|
||||
|
||||
DELETE FROM message_deliveries
|
||||
WHERE recipient_role_name = 'user';
|
||||
@@ -0,0 +1,70 @@
|
||||
CREATE TABLE IF NOT EXISTS lanes (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
base_branch TEXT NOT NULL DEFAULT '',
|
||||
branch_name TEXT NOT NULL DEFAULT '',
|
||||
worktree_path TEXT NOT NULL DEFAULT '',
|
||||
container_name TEXT NOT NULL DEFAULT '',
|
||||
runtime_endpoint TEXT NOT NULL DEFAULT '',
|
||||
created_by_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
result_summary_markdown TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
UNIQUE(topic_id, slug),
|
||||
UNIQUE(workspace_id, branch_name),
|
||||
UNIQUE(workspace_id, worktree_path),
|
||||
UNIQUE(workspace_id, container_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lanes_topic ON lanes(topic_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_lanes_workspace_status ON lanes(workspace_id, status, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
lane_id TEXT NOT NULL REFERENCES lanes(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
body_markdown TEXT NOT NULL,
|
||||
acceptance_markdown TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
task_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_by_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
blocking_reason_markdown TEXT NOT NULL DEFAULT '',
|
||||
result_summary_markdown TEXT NOT NULL DEFAULT '',
|
||||
assigned_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_topic ON tasks(topic_id, status, priority, task_order, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_lane ON tasks(lane_id, status, task_order, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_dependencies (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
depends_on_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (task_id, depends_on_task_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_dependencies_upstream ON task_dependencies(depends_on_task_id, task_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
body_markdown TEXT NOT NULL DEFAULT '',
|
||||
created_by_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, created_at);
|
||||
@@ -0,0 +1,115 @@
|
||||
DELETE FROM workflow_run_logs
|
||||
WHERE run_id IN (
|
||||
SELECT id
|
||||
FROM workflow_runs
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM merge_requests
|
||||
WHERE requested_by_role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
)
|
||||
OR workflow_run_id IN (
|
||||
SELECT id
|
||||
FROM workflow_runs
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM workflow_runs
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM message_deliveries
|
||||
WHERE recipient_role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM role_threads
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM human_tasks
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM messages
|
||||
WHERE from_role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
)
|
||||
OR to_expr LIKE '%product%'
|
||||
OR to_expr LIKE '%backend%'
|
||||
OR to_expr LIKE '%frontend%'
|
||||
OR to_expr LIKE '%reviewer%'
|
||||
OR to_expr LIKE '%discovery_ux%'
|
||||
OR to_expr LIKE '%discovery_quality%'
|
||||
OR to_expr LIKE '%discovery_growth%';
|
||||
|
||||
DELETE FROM topic_documents
|
||||
WHERE topic_id IN (
|
||||
SELECT id
|
||||
FROM topics
|
||||
WHERE space IN ('pool', 'discovery')
|
||||
);
|
||||
|
||||
DELETE FROM topics
|
||||
WHERE space IN ('pool', 'discovery');
|
||||
|
||||
DROP TABLE IF EXISTS discovery_votes;
|
||||
DROP TABLE IF EXISTS discovery_candidates;
|
||||
DROP TABLE IF EXISTS discovery_rounds;
|
||||
DROP TABLE IF EXISTS requirements;
|
||||
@@ -0,0 +1,76 @@
|
||||
DELETE FROM task_events
|
||||
WHERE created_by_role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM tasks
|
||||
WHERE created_by_role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM lanes
|
||||
WHERE created_by_role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM role_skill_bindings
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM role_configs
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM role_prompts
|
||||
WHERE role_name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
|
||||
DELETE FROM roles
|
||||
WHERE name IN (
|
||||
'product',
|
||||
'backend',
|
||||
'frontend',
|
||||
'reviewer',
|
||||
'discovery_ux',
|
||||
'discovery_quality',
|
||||
'discovery_growth'
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks ADD COLUMN task_kind TEXT NOT NULL DEFAULT 'execution';
|
||||
ALTER TABLE tasks ADD COLUMN gate_policy TEXT NOT NULL DEFAULT 'none';
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE lanes ADD COLUMN purpose TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE tasks ADD COLUMN deliverables_json TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE tasks ADD COLUMN verification_mode TEXT NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE tasks ADD COLUMN batch_key TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tasks ADD COLUMN replan_policy TEXT NOT NULL DEFAULT 'patch';
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS task_graph_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
plan_json TEXT NOT NULL DEFAULT '{}',
|
||||
plan_summary_markdown TEXT NOT NULL DEFAULT '',
|
||||
created_by_role_name TEXT NOT NULL REFERENCES roles(name),
|
||||
created_at TEXT NOT NULL,
|
||||
confirmed_at TEXT,
|
||||
supersedes_graph_version_id TEXT REFERENCES task_graph_versions(id) ON DELETE SET NULL,
|
||||
UNIQUE(topic_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_graph_versions_topic ON task_graph_versions(topic_id, version DESC, created_at DESC);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE chains RENAME TO lanes;
|
||||
ALTER TABLE tasks RENAME COLUMN chain_id TO lane_id;
|
||||
DROP INDEX IF EXISTS idx_chains_topic;
|
||||
DROP INDEX IF EXISTS idx_chains_workspace_status;
|
||||
DROP INDEX IF EXISTS idx_tasks_chain;
|
||||
CREATE INDEX IF NOT EXISTS idx_lanes_topic ON lanes(topic_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_lanes_workspace_status ON lanes(workspace_id, status, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_lane ON tasks(lane_id, status, task_order, created_at);
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE lanes ADD COLUMN head_commit TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lane_syncs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
downstream_lane_id TEXT NOT NULL REFERENCES lanes(id) ON DELETE CASCADE,
|
||||
upstream_lane_id TEXT NOT NULL REFERENCES lanes(id) ON DELETE CASCADE,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
upstream_commit TEXT NOT NULL,
|
||||
merge_commit TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_syncs_task ON lane_syncs(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_syncs_downstream ON lane_syncs(downstream_lane_id, created_at);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE workspaces DROP COLUMN agent_max_jobs;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE roles DROP COLUMN category;
|
||||
@@ -0,0 +1,19 @@
|
||||
UPDATE roles
|
||||
SET description = 'Runs on the host, talks to the user, maintains the task graph, and orchestrates execution.'
|
||||
WHERE name = 'leader' AND is_builtin = 1;
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 leader。
|
||||
|
||||
你运行在宿主机上,是当前主题唯一的编排者。你负责与用户沟通、澄清目标、维护 task graph、启动执行,并汇总最终结果。lane 由系统从任务图自动派生。
|
||||
|
||||
规则:
|
||||
- 先理解用户目标,再拆分任务;不要直接跳过澄清。
|
||||
- 所有 task 都必须明确目标、依赖、验收标准。
|
||||
- worker 只能向你升级阻塞;是否打扰用户由你决定。
|
||||
- 你可以创建、更新、启动、停止 lane 与 task,但不要代替 worker 在容器里完成实现。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0013',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-leader' AND updated_by = 'builtin-seed';
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS role_threads;
|
||||
|
||||
ALTER TABLE workspaces DROP COLUMN runtime_ref;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE workflow_runs DROP COLUMN thread_id;
|
||||
ALTER TABLE workflow_runs DROP COLUMN prior_thread_id;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE workspaces DROP COLUMN runtime_endpoint;
|
||||
|
||||
DROP TABLE IF EXISTS config_change_logs;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE topics DROP COLUMN source_topic_id;
|
||||
ALTER TABLE topics DROP COLUMN owner_role_name;
|
||||
ALTER TABLE topics DROP COLUMN meta_json;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE messages DROP COLUMN round;
|
||||
|
||||
ALTER TABLE message_deliveries DROP COLUMN read_at;
|
||||
ALTER TABLE message_deliveries DROP COLUMN archived_at;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE role_configs DROP COLUMN plan_model;
|
||||
ALTER TABLE role_configs DROP COLUMN plan_reasoning_effort;
|
||||
ALTER TABLE role_configs DROP COLUMN disable_response_storage;
|
||||
@@ -0,0 +1 @@
|
||||
-- Reserved no-op migration to preserve contiguous historical numbering.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
DELETE FROM role_prompts
|
||||
WHERE prompt_kind <> 'system';
|
||||
|
||||
ALTER TABLE tasks DROP COLUMN gate_policy;
|
||||
ALTER TABLE tasks DROP COLUMN verification_mode;
|
||||
ALTER TABLE tasks DROP COLUMN replan_policy;
|
||||
@@ -0,0 +1 @@
|
||||
-- Reserved no-op migration to preserve contiguous historical numbering.
|
||||
@@ -0,0 +1 @@
|
||||
-- Reserved no-op migration to preserve contiguous historical numbering.
|
||||
@@ -0,0 +1 @@
|
||||
-- Reserved no-op migration to preserve contiguous historical numbering.
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE role_configs DROP COLUMN provider_name;
|
||||
|
||||
DROP TABLE IF EXISTS merge_requests;
|
||||
DROP TABLE IF EXISTS topic_documents;
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 leader。
|
||||
|
||||
你运行在宿主机上,是当前主题唯一的编排者。你负责与用户沟通、澄清目标、维护 task graph、启动执行,并汇总最终结果。lane 由系统从任务图自动派生。
|
||||
|
||||
规则:
|
||||
- 先理解用户目标,再拆分任务;不要直接跳过澄清。
|
||||
- 所有 task 都必须明确目标、依赖、验收标准。
|
||||
- worker 的问题和总结都只作为编排输入,不要把它们直接当作完成证明。
|
||||
- worker 只能向你升级阻塞;是否打扰用户由你决定。
|
||||
- 你可以创建、更新、启动、停止 lane 与 task,但不要代替 worker 在容器里完成实现。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0029',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-leader';
|
||||
|
||||
UPDATE role_prompts
|
||||
SET
|
||||
content_markdown = '你是 worker。
|
||||
|
||||
你运行在一个 lane 对应的独立 worktree 与容器中。你只负责执行当前被分派的 task,并通过 inbox 向 leader 汇报进度、结果或阻塞。
|
||||
|
||||
规则:
|
||||
- 只处理当前 task,不要自行扩展范围。
|
||||
- 在开始前阅读 task、验收标准和 lane 上下文。
|
||||
- 遇到阻塞时,先通过 inbox 向 leader 发一条具体问题,再结束当前 task,并在总结里写清阻塞点与所需决策。
|
||||
- 行为变化时补充必要的验证与测试。',
|
||||
version = version + 1,
|
||||
updated_by = 'builtin-migration-0029',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-role-prompt-worker';
|
||||
|
||||
UPDATE skills
|
||||
SET
|
||||
content_markdown = '---
|
||||
name: inbox
|
||||
description: Use Inbox V2 from a role runtime. Supports two operations only: send and ask. Use this when you need to post a workflow message or a blocking question into an existing topic with inbox api.
|
||||
---
|
||||
|
||||
# Inbox
|
||||
|
||||
Use this skill when a role needs to communicate through Inbox V2.
|
||||
|
||||
This skill has two operations only:
|
||||
- send: post a normal workflow message into an existing topic
|
||||
- ask: post a blocking question that needs an answer from another role
|
||||
|
||||
## Before using either operation
|
||||
|
||||
Resolve the topic id first if you only know the workspace and topic slug:
|
||||
|
||||
```bash
|
||||
inbox api GET "/api/v2/topics?workspace_id=<workspace_id>"
|
||||
```
|
||||
|
||||
Pick the target topic record, then use its id in the message API below.
|
||||
|
||||
## send
|
||||
|
||||
Use send for handoffs, updates, decisions, or summaries.
|
||||
|
||||
```bash
|
||||
inbox api POST /api/v2/topics/<topic_id>/messages --data {
|
||||
"workspace_id": "<workspace_id>",
|
||||
"from_role_name": "<current_role>",
|
||||
"to_expr": "<target_role>",
|
||||
"type": "chat",
|
||||
"stage": "execution",
|
||||
"body_markdown": "<message markdown>"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Set type to the actual intent: chat, proposal, decision, or summary.
|
||||
- Keep stage aligned with the current workflow state.
|
||||
- Add reply_to_message_id when replying to a specific message.
|
||||
|
||||
## ask
|
||||
|
||||
Use ask when you are blocked and need a concrete answer.
|
||||
|
||||
```bash
|
||||
inbox api POST /api/v2/topics/<topic_id>/messages --data {
|
||||
"workspace_id": "<workspace_id>",
|
||||
"from_role_name": "<current_role>",
|
||||
"to_expr": "<target_role>",
|
||||
"type": "question",
|
||||
"stage": "<current_stage>",
|
||||
"body_markdown": "<single concrete question and the decision you need>"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Ask one concrete blocker per message.
|
||||
- State the decision, artifact, or approval you need back.
|
||||
- Keep stage aligned with the task or planning stage you are in.
|
||||
- Prefer ask over vague status pings.',
|
||||
description = 'Use Inbox V2 from a role runtime with two operations: send and ask.',
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-skill-inbox';
|
||||
@@ -0,0 +1 @@
|
||||
-- handled by custom Go migration logic in applyMigration
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
UPDATE skills
|
||||
SET
|
||||
content_markdown = '---
|
||||
name: inbox
|
||||
description: "Use Inbox V2 from a role runtime. Supports two operations only: send and ask. Use this when you need to post a workflow message or a blocking question into an existing topic with inbox api."
|
||||
---
|
||||
|
||||
# Inbox
|
||||
|
||||
Use this skill when a role needs to communicate through Inbox V2.
|
||||
|
||||
This skill has two operations only:
|
||||
- send: post a normal workflow message into an existing topic
|
||||
- ask: post a blocking question that needs an answer from another role
|
||||
|
||||
## Before using either operation
|
||||
|
||||
Resolve the topic id first if you only know the workspace and topic slug:
|
||||
|
||||
```bash
|
||||
inbox api GET "/api/v2/topics?workspace_id=<workspace_id>"
|
||||
```
|
||||
|
||||
Pick the target topic record, then use its id in the message API below.
|
||||
|
||||
## send
|
||||
|
||||
Use send for handoffs, updates, decisions, or summaries.
|
||||
|
||||
```bash
|
||||
inbox api POST /api/v2/topics/<topic_id>/messages --data {
|
||||
"workspace_id": "<workspace_id>",
|
||||
"from_role_name": "<current_role>",
|
||||
"to_expr": "<target_role>",
|
||||
"type": "chat",
|
||||
"stage": "execution",
|
||||
"body_markdown": "<message markdown>"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Set type to the actual intent: chat, proposal, decision, or summary.
|
||||
- Keep stage aligned with the current workflow state.
|
||||
- Add reply_to_message_id when replying to a specific message.
|
||||
|
||||
## ask
|
||||
|
||||
Use ask when you are blocked and need a concrete answer.
|
||||
|
||||
```bash
|
||||
inbox api POST /api/v2/topics/<topic_id>/messages --data {
|
||||
"workspace_id": "<workspace_id>",
|
||||
"from_role_name": "<current_role>",
|
||||
"to_expr": "<target_role>",
|
||||
"type": "question",
|
||||
"stage": "<current_stage>",
|
||||
"body_markdown": "<single concrete question and the decision you need>"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Ask one concrete blocker per message.
|
||||
- State the decision, artifact, or approval you need back.
|
||||
- Keep stage aligned with the task or planning stage you are in.
|
||||
- Prefer ask over vague status pings.',
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 'builtin-skill-inbox';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE skills DROP COLUMN source_ref;
|
||||
ALTER TABLE skills DROP COLUMN asset_root;
|
||||
@@ -0,0 +1,234 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
func (s *Store) CreateProject(ctx context.Context, value workspace.Project) (workspace.Project, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return workspace.Project{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("project")
|
||||
if err != nil {
|
||||
return workspace.Project{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
now := s.now()
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, now)
|
||||
value.UpdatedAt = now
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO projects(id, slug, name, root_path, default_branch, status, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, value.ID, value.Slug, value.Name, value.RootPath, value.DefaultBranch, value.Status, value.CreatedAt, value.UpdatedAt); err != nil {
|
||||
return workspace.Project{}, fmt.Errorf("create project: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListProjects(ctx context.Context) ([]workspace.Project, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, slug, name, root_path, default_branch, status, created_at, updated_at
|
||||
FROM projects
|
||||
ORDER BY created_at, slug
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []workspace.Project
|
||||
for rows.Next() {
|
||||
item, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate projects: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(ctx context.Context, projectID string) (workspace.Project, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, slug, name, root_path, default_branch, status, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE id = ?
|
||||
`, projectID)
|
||||
return scanProject(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetProjectByRootPath(ctx context.Context, rootPath string) (workspace.Project, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, slug, name, root_path, default_branch, status, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE root_path = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, rootPath)
|
||||
return scanProject(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetProjectBySlug(ctx context.Context, slug string) (workspace.Project, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, slug, name, root_path, default_branch, status, created_at, updated_at
|
||||
FROM projects
|
||||
WHERE slug = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, slug)
|
||||
return scanProject(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreateWorkspace(ctx context.Context, value workspace.Workspace) (workspace.Workspace, error) {
|
||||
value.ProvisionState = coalesceString(value.ProvisionState, "pending")
|
||||
if err := value.Validate(); err != nil {
|
||||
return workspace.Workspace{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("workspace")
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
now := s.now()
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, now)
|
||||
value.UpdatedAt = now
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO workspaces(id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, provision_state, provision_error, last_provisioned_at, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, value.ID, value.ProjectID, value.Slug, value.Name, value.RootPath, value.BaseBranch, value.WorktreeBranch, value.RuntimeBackend, value.Status, value.ProvisionState, value.ProvisionError, nullableString(value.LastProvisionedAt), value.CreatedAt, value.UpdatedAt); err != nil {
|
||||
return workspace.Workspace{}, fmt.Errorf("create workspace: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListWorkspaces(ctx context.Context, projectID string) ([]workspace.Workspace, error) {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if projectID == "" {
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, provision_state, provision_error, last_provisioned_at, created_at, updated_at
|
||||
FROM workspaces
|
||||
ORDER BY created_at, slug
|
||||
`)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, provision_state, provision_error, last_provisioned_at, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at, slug
|
||||
`, projectID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workspaces: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []workspace.Workspace
|
||||
for rows.Next() {
|
||||
item, err := scanWorkspace(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate workspaces: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetWorkspace(ctx context.Context, workspaceID string) (workspace.Workspace, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, provision_state, provision_error, last_provisioned_at, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE id = ?
|
||||
`, workspaceID)
|
||||
return scanWorkspace(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetWorkspaceBySlugOrName(ctx context.Context, value string) (workspace.Workspace, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, provision_state, provision_error, last_provisioned_at, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE slug = ? OR name = ?
|
||||
ORDER BY CASE WHEN slug = ? THEN 0 ELSE 1 END, created_at DESC
|
||||
LIMIT 1
|
||||
`, value, value, value)
|
||||
return scanWorkspace(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetWorkspaceByProjectAndSlug(ctx context.Context, projectID, slug string) (workspace.Workspace, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, slug, name, root_path, base_branch, worktree_branch, runtime_backend, status, provision_state, provision_error, last_provisioned_at, created_at, updated_at
|
||||
FROM workspaces
|
||||
WHERE project_id = ? AND slug = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, projectID, slug)
|
||||
return scanWorkspace(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProjectDefaultBranch(ctx context.Context, projectID, defaultBranch string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE projects
|
||||
SET default_branch = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, defaultBranch, s.now(), projectID); err != nil {
|
||||
return fmt.Errorf("update project default branch: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateWorkspace(ctx context.Context, value workspace.Workspace) error {
|
||||
value.UpdatedAt = s.now()
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE workspaces
|
||||
SET root_path = ?,
|
||||
base_branch = ?,
|
||||
worktree_branch = ?,
|
||||
runtime_backend = ?,
|
||||
status = ?,
|
||||
provision_state = ?,
|
||||
provision_error = ?,
|
||||
last_provisioned_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, value.RootPath, value.BaseBranch, value.WorktreeBranch, value.RuntimeBackend, value.Status, value.ProvisionState, value.ProvisionError, nullableString(value.LastProvisionedAt), value.UpdatedAt, value.ID); err != nil {
|
||||
return fmt.Errorf("update workspace: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanProject(s scanner) (workspace.Project, error) {
|
||||
var item workspace.Project
|
||||
if err := s.Scan(&item.ID, &item.Slug, &item.Name, &item.RootPath, &item.DefaultBranch, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return workspace.Project{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanWorkspace(s scanner) (workspace.Workspace, error) {
|
||||
var item workspace.Workspace
|
||||
var lastProvisionedAt sql.NullString
|
||||
if err := s.Scan(&item.ID, &item.ProjectID, &item.Slug, &item.Name, &item.RootPath, &item.BaseBranch, &item.WorktreeBranch, &item.RuntimeBackend, &item.Status, &item.ProvisionState, &item.ProvisionError, &lastProvisionedAt, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return workspace.Workspace{}, err
|
||||
}
|
||||
if lastProvisionedAt.Valid {
|
||||
item.LastProvisionedAt = lastProvisionedAt.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
)
|
||||
|
||||
type legacyRoleConfig struct {
|
||||
RoleName string
|
||||
Model string
|
||||
ModelProvider string
|
||||
ProviderBaseURL string
|
||||
ProviderWireAPI string
|
||||
ReasoningEffort string
|
||||
ShellEnvInherit string
|
||||
ShellEnvOverrides map[string]string
|
||||
ExtraConfig map[string]any
|
||||
}
|
||||
|
||||
func applyRoleConfigCollapseMigration(ctx context.Context, db *sql.DB, m migration, clock timeutil.Clock) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", m.Name, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := ensureRoleColumn(ctx, tx, "config_toml", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return fmt.Errorf("ensure roles.config_toml: %w", err)
|
||||
}
|
||||
if err := ensureRoleColumn(ctx, tx, "auth_json", "TEXT NOT NULL DEFAULT '{}'"); err != nil {
|
||||
return fmt.Errorf("ensure roles.auth_json: %w", err)
|
||||
}
|
||||
|
||||
exists, err := tableExistsTx(ctx, tx, "role_configs")
|
||||
if err != nil {
|
||||
return fmt.Errorf("check role_configs existence: %w", err)
|
||||
}
|
||||
if exists {
|
||||
configs, err := loadCollapsedLegacyRoleConfigs(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load role_configs: %w", err)
|
||||
}
|
||||
now := timeutil.FormatRFC3339(clock.Now())
|
||||
for _, item := range configs {
|
||||
authJSON, err := renderLegacyRoleAuthJSON(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render auth for role %s: %w", item.RoleName, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE roles
|
||||
SET config_toml = ?, auth_json = ?, updated_at = ?
|
||||
WHERE name = ?
|
||||
`, renderLegacyRoleConfigTOML(item), authJSON, now, item.RoleName); err != nil {
|
||||
return fmt.Errorf("copy role config for %s: %w", item.RoleName, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DROP TABLE role_configs`); err != nil {
|
||||
return fmt.Errorf("drop role_configs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, ?)`,
|
||||
m.Version, m.Name, timeutil.FormatRFC3339(clock.Now())); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", m.Name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", m.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureRoleColumn(ctx context.Context, tx *sql.Tx, name, definition string) error {
|
||||
exists, err := columnExistsTx(ctx, tx, "roles", name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `ALTER TABLE roles ADD COLUMN `+name+` `+definition)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCollapsedLegacyRoleConfigs(ctx context.Context, tx *sql.Tx) ([]legacyRoleConfig, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT role_name, model, model_provider, provider_base_url, provider_wire_api,
|
||||
reasoning_effort, shell_env_inherit, shell_env_overrides_json, extra_config_json
|
||||
FROM role_configs
|
||||
ORDER BY role_name,
|
||||
CASE WHEN workspace_id IS NULL THEN 0 ELSE 1 END,
|
||||
updated_at DESC,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]legacyRoleConfig, 0)
|
||||
seen := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
item legacyRoleConfig
|
||||
shellEnvJSON string
|
||||
extraJSON string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&item.RoleName,
|
||||
&item.Model,
|
||||
&item.ModelProvider,
|
||||
&item.ProviderBaseURL,
|
||||
&item.ProviderWireAPI,
|
||||
&item.ReasoningEffort,
|
||||
&item.ShellEnvInherit,
|
||||
&shellEnvJSON,
|
||||
&extraJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[item.RoleName]; ok {
|
||||
continue
|
||||
}
|
||||
if err := unmarshalJSON(shellEnvJSON, &item.ShellEnvOverrides); err != nil {
|
||||
return nil, fmt.Errorf("decode shell env overrides for %s: %w", item.RoleName, err)
|
||||
}
|
||||
if err := unmarshalJSON(extraJSON, &item.ExtraConfig); err != nil {
|
||||
return nil, fmt.Errorf("decode extra config for %s: %w", item.RoleName, err)
|
||||
}
|
||||
out = append(out, item)
|
||||
seen[item.RoleName] = struct{}{}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderLegacyRoleAuthJSON(config legacyRoleConfig) (string, error) {
|
||||
auth := map[string]any{}
|
||||
if raw, ok := legacyLookupMap(config.ExtraConfig, "auth_json"); ok {
|
||||
auth = raw
|
||||
}
|
||||
data, err := json.MarshalIndent(auth, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func renderLegacyRoleConfigTOML(config legacyRoleConfig) string {
|
||||
var builder strings.Builder
|
||||
|
||||
if strings.TrimSpace(config.Model) != "" {
|
||||
legacyWriteTomlKV(&builder, "model", config.Model)
|
||||
}
|
||||
providerKey := strings.TrimSpace(config.ModelProvider)
|
||||
if providerKey != "" {
|
||||
legacyWriteTomlKV(&builder, "model_provider", providerKey)
|
||||
}
|
||||
legacyWriteTomlKV(&builder, "approval_policy", "never")
|
||||
legacyWriteTomlKV(&builder, "sandbox_mode", "workspace-write")
|
||||
legacyWriteTomlKV(&builder, "cli_auth_credentials_store", "file")
|
||||
if strings.TrimSpace(config.ReasoningEffort) != "" {
|
||||
legacyWriteTomlKV(&builder, "reasoning_effort", config.ReasoningEffort)
|
||||
legacyWriteTomlKV(&builder, "model_reasoning_effort", config.ReasoningEffort)
|
||||
}
|
||||
|
||||
builder.WriteString("\n[shell_environment_policy]\n")
|
||||
legacyWriteTomlKV(&builder, "inherit", legacyFirstNonEmpty(config.ShellEnvInherit, "core"))
|
||||
legacyWriteTomlKV(&builder, "exclude", []string{})
|
||||
legacyWriteTomlKV(&builder, "include_only", []string{})
|
||||
legacyWriteTomlKV(&builder, "experimental_use_profile", false)
|
||||
if len(config.ShellEnvOverrides) > 0 {
|
||||
builder.WriteString("\n[shell_environment_policy.set]\n")
|
||||
for _, key := range legacySortedKeysStringMap(config.ShellEnvOverrides) {
|
||||
legacyWriteTomlKV(&builder, key, config.ShellEnvOverrides[key])
|
||||
}
|
||||
}
|
||||
|
||||
providers := map[string]any{}
|
||||
if raw, ok := legacyLookupMap(config.ExtraConfig, "model_providers"); ok {
|
||||
for key, value := range raw {
|
||||
providers[key] = value
|
||||
}
|
||||
}
|
||||
if providerKey != "" {
|
||||
provider := map[string]any{}
|
||||
if raw, ok := providers[providerKey]; ok {
|
||||
if existing, ok := legacyToStringAnyMap(raw); ok {
|
||||
for key, value := range existing {
|
||||
provider[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
provider["name"] = providerKey
|
||||
if strings.TrimSpace(config.ProviderBaseURL) != "" {
|
||||
provider["base_url"] = config.ProviderBaseURL
|
||||
}
|
||||
if strings.TrimSpace(config.ProviderWireAPI) != "" {
|
||||
provider["wire_api"] = config.ProviderWireAPI
|
||||
}
|
||||
providers[providerKey] = provider
|
||||
}
|
||||
if len(providers) > 0 {
|
||||
legacyAppendTomlTableMap(&builder, []string{"model_providers"}, providers)
|
||||
}
|
||||
|
||||
if mcpServers, ok := legacyLookupMap(config.ExtraConfig, "mcp_servers"); ok && len(mcpServers) > 0 {
|
||||
legacyAppendTomlTableMap(&builder, []string{"mcp_servers"}, mcpServers)
|
||||
}
|
||||
|
||||
builder.WriteString("\n[projects.")
|
||||
builder.WriteString(legacyFormatTomlKey("/workspace"))
|
||||
builder.WriteString("]\n")
|
||||
legacyWriteTomlKV(&builder, "trust_level", "trusted")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func tableExistsTx(ctx context.Context, tx *sql.Tx, name string) (bool, error) {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = ?
|
||||
`, name).Scan(&count); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func columnExistsTx(ctx context.Context, tx *sql.Tx, tableName, columnName string) (bool, error) {
|
||||
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+tableName+`)`)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
columnType string
|
||||
notNull int
|
||||
defaultVal sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == columnName {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func legacyAppendTomlTableMap(builder *strings.Builder, prefix []string, table map[string]any) {
|
||||
for _, key := range legacySortedKeysAnyMap(table) {
|
||||
child, ok := legacyToStringAnyMap(table[key])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
legacyAppendTomlTable(builder, append(prefix, key), child)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyAppendTomlTable(builder *strings.Builder, pathParts []string, table map[string]any) {
|
||||
builder.WriteString("\n[")
|
||||
builder.WriteString(legacyFormatTomlPath(pathParts))
|
||||
builder.WriteString("]\n")
|
||||
|
||||
scalars := make([]string, 0, len(table))
|
||||
children := make([]string, 0, len(table))
|
||||
for key, value := range table {
|
||||
if _, ok := legacyToStringAnyMap(value); ok {
|
||||
children = append(children, key)
|
||||
continue
|
||||
}
|
||||
scalars = append(scalars, key)
|
||||
}
|
||||
sort.Strings(scalars)
|
||||
sort.Strings(children)
|
||||
|
||||
for _, key := range scalars {
|
||||
encoded, err := legacyEncodeTomlValue(table[key])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(legacyFormatTomlKey(key))
|
||||
builder.WriteString(" = ")
|
||||
builder.WriteString(encoded)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
for _, key := range children {
|
||||
child, _ := legacyToStringAnyMap(table[key])
|
||||
legacyAppendTomlTable(builder, append(pathParts, key), child)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyEncodeTomlValue(value any) (string, error) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strconv.Quote(typed), nil
|
||||
case bool:
|
||||
if typed {
|
||||
return "true", nil
|
||||
}
|
||||
return "false", nil
|
||||
case int:
|
||||
return strconv.Itoa(typed), nil
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10), nil
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return strconv.FormatInt(int64(typed), 10), nil
|
||||
}
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64), nil
|
||||
case []string:
|
||||
items := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
items = append(items, strconv.Quote(item))
|
||||
}
|
||||
return "[ " + strings.Join(items, ", ") + " ]", nil
|
||||
case []any:
|
||||
items := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
encoded, err := legacyEncodeTomlValue(item)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items = append(items, encoded)
|
||||
}
|
||||
return "[ " + strings.Join(items, ", ") + " ]", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported TOML value type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyWriteTomlKV(builder *strings.Builder, key string, value any) {
|
||||
encoded, err := legacyEncodeTomlValue(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
builder.WriteString(legacyFormatTomlKey(key))
|
||||
builder.WriteString(" = ")
|
||||
builder.WriteString(encoded)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
func legacyFormatTomlPath(parts []string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
out = append(out, legacyFormatTomlKey(part))
|
||||
}
|
||||
return strings.Join(out, ".")
|
||||
}
|
||||
|
||||
func legacyFormatTomlKey(value string) string {
|
||||
if value != "" && legacyIsBareTomlKey(value) {
|
||||
return value
|
||||
}
|
||||
return strconv.Quote(value)
|
||||
}
|
||||
|
||||
func legacyIsBareTomlKey(value string) bool {
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func legacyFirstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func legacyLookupMap(source map[string]any, key string) (map[string]any, bool) {
|
||||
if len(source) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
value, ok := source[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return legacyToStringAnyMap(value)
|
||||
}
|
||||
|
||||
func legacyToStringAnyMap(value any) (map[string]any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return typed, true
|
||||
case map[string]string:
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
out[key] = item
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func legacySortedKeysAnyMap(value map[string]any) []string {
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func legacySortedKeysStringMap(value map[string]string) []string {
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/role"
|
||||
)
|
||||
|
||||
func (s *Store) GetRole(ctx context.Context, name string) (role.Definition, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT name, title, executor_kind, description, is_enabled, is_builtin, sort_order, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE name = ?
|
||||
`, name)
|
||||
return scanRole(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListRoles(ctx context.Context) ([]role.Definition, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT name, title, executor_kind, description, is_enabled, is_builtin, sort_order, created_at, updated_at
|
||||
FROM roles
|
||||
ORDER BY sort_order, name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []role.Definition
|
||||
for rows.Next() {
|
||||
item, err := scanRole(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate roles: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRole(ctx context.Context, value role.Definition, changedBy string) (role.Definition, error) {
|
||||
_ = changedBy
|
||||
value = role.NormalizeDefinition(value)
|
||||
if err := value.Validate(); err != nil {
|
||||
return role.Definition{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return role.Definition{}, fmt.Errorf("begin upsert role: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
before, err := getRoleTx(ctx, tx, value.Name)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return role.Definition{}, err
|
||||
}
|
||||
now := s.now()
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
value.CreatedAt = now
|
||||
} else {
|
||||
value.CreatedAt = before.CreatedAt
|
||||
}
|
||||
value.UpdatedAt = now
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO roles(name, title, executor_kind, description, is_enabled, is_builtin, sort_order, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
executor_kind = excluded.executor_kind,
|
||||
description = excluded.description,
|
||||
is_enabled = excluded.is_enabled,
|
||||
is_builtin = excluded.is_builtin,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
value.Name,
|
||||
value.Title,
|
||||
string(value.ExecutorKind),
|
||||
value.Description,
|
||||
boolToInt(value.IsEnabled),
|
||||
boolToInt(value.IsBuiltin),
|
||||
value.SortOrder,
|
||||
value.CreatedAt,
|
||||
value.UpdatedAt,
|
||||
); err != nil {
|
||||
return role.Definition{}, fmt.Errorf("upsert role %q: %w", value.Name, err)
|
||||
}
|
||||
after, err := getRoleTx(ctx, tx, value.Name)
|
||||
if err != nil {
|
||||
return role.Definition{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return role.Definition{}, fmt.Errorf("commit upsert role: %w", err)
|
||||
}
|
||||
return after, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRolePrompt(ctx context.Context, value role.Prompt, changedBy string) (role.Prompt, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return role.Prompt{}, err
|
||||
}
|
||||
return upsertVersionedConfig(ctx, s, value, changedBy, versionedConfigUpsertSpec[role.Prompt]{
|
||||
entityName: "role prompt",
|
||||
idKind: "role-prompt",
|
||||
loadTx: func(ctx context.Context, tx *sql.Tx) (role.Prompt, error) {
|
||||
return getRolePromptTx(ctx, tx, value.RoleName, value.WorkspaceID, value.PromptKind)
|
||||
},
|
||||
current: func(item role.Prompt) versionedConfigCurrent {
|
||||
return versionedConfigCurrent{
|
||||
ID: item.ID,
|
||||
Version: item.Version,
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
},
|
||||
applyMetadata: func(item *role.Prompt, meta versionedConfigMetadata) {
|
||||
item.ID = meta.ID
|
||||
item.Version = meta.Version
|
||||
item.CreatedAt = meta.CreatedAt
|
||||
item.UpdatedAt = meta.UpdatedAt
|
||||
},
|
||||
writeTx: func(ctx context.Context, tx *sql.Tx, item role.Prompt) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO role_prompts(id, role_name, workspace_id, prompt_kind, content_markdown, version, updated_by, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
content_markdown = excluded.content_markdown,
|
||||
version = excluded.version,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
item.ID,
|
||||
item.RoleName,
|
||||
nullableString(item.WorkspaceID),
|
||||
string(item.PromptKind),
|
||||
item.ContentMarkdown,
|
||||
item.Version,
|
||||
coalesceString(changedBy, item.UpdatedBy),
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListRolePrompts(ctx context.Context, roleName string) ([]role.Prompt, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, role_name, workspace_id, prompt_kind, content_markdown, version, updated_by, created_at, updated_at
|
||||
FROM role_prompts
|
||||
WHERE role_name = ?
|
||||
ORDER BY prompt_kind, workspace_id, updated_at
|
||||
`, roleName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list role prompts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []role.Prompt
|
||||
for rows.Next() {
|
||||
item, err := scanRolePrompt(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate role prompts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRoleConfig(ctx context.Context, value role.Config, changedBy string) (role.Config, error) {
|
||||
_ = changedBy
|
||||
value = role.NormalizeConfig(value)
|
||||
if err := value.Validate(); err != nil {
|
||||
return role.Config{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return role.Config{}, fmt.Errorf("begin upsert role config: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE roles
|
||||
SET config_toml = ?, auth_json = ?, updated_at = ?
|
||||
WHERE name = ?
|
||||
`, value.ConfigTOML, value.AuthJSON, s.now(), value.RoleName)
|
||||
if err != nil {
|
||||
return role.Config{}, fmt.Errorf("upsert role config: %w", err)
|
||||
}
|
||||
if err := ensureAffected(result, sql.ErrNoRows); err != nil {
|
||||
return role.Config{}, err
|
||||
}
|
||||
after, err := getRoleConfigTx(ctx, tx, value.RoleName)
|
||||
if err != nil {
|
||||
return role.Config{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return role.Config{}, fmt.Errorf("commit upsert role config: %w", err)
|
||||
}
|
||||
return after, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetRoleConfig(ctx context.Context, roleName string) (role.Config, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT name, config_toml, auth_json
|
||||
FROM roles
|
||||
WHERE name = ?
|
||||
`, roleName)
|
||||
return scanRoleConfig(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRoleSkillBinding(ctx context.Context, value role.SkillBinding, changedBy string) (role.SkillBinding, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return role.SkillBinding{}, err
|
||||
}
|
||||
return upsertVersionedConfig(ctx, s, value, changedBy, versionedConfigUpsertSpec[role.SkillBinding]{
|
||||
entityName: "role skill binding",
|
||||
idKind: "role-skill-binding",
|
||||
loadTx: func(ctx context.Context, tx *sql.Tx) (role.SkillBinding, error) {
|
||||
return getRoleSkillBindingTx(ctx, tx, value.RoleName, value.WorkspaceID, value.SkillID)
|
||||
},
|
||||
current: func(item role.SkillBinding) versionedConfigCurrent {
|
||||
return versionedConfigCurrent{
|
||||
ID: item.ID,
|
||||
Version: item.Version,
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
},
|
||||
applyMetadata: func(item *role.SkillBinding, meta versionedConfigMetadata) {
|
||||
item.ID = meta.ID
|
||||
item.Version = meta.Version
|
||||
item.CreatedAt = meta.CreatedAt
|
||||
item.UpdatedAt = meta.UpdatedAt
|
||||
},
|
||||
writeTx: func(ctx context.Context, tx *sql.Tx, item role.SkillBinding) error {
|
||||
configJSON, err := marshalJSONMapAny(item.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO role_skill_bindings(id, role_name, workspace_id, skill_id, is_enabled, sort_order, config_json, version, updated_by, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
is_enabled = excluded.is_enabled,
|
||||
sort_order = excluded.sort_order,
|
||||
config_json = excluded.config_json,
|
||||
version = excluded.version,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
item.ID,
|
||||
item.RoleName,
|
||||
nullableString(item.WorkspaceID),
|
||||
item.SkillID,
|
||||
boolToInt(item.IsEnabled),
|
||||
item.SortOrder,
|
||||
configJSON,
|
||||
item.Version,
|
||||
coalesceString(changedBy, item.UpdatedBy),
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListRoleSkillBindings(ctx context.Context, roleName string) ([]role.SkillBinding, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, role_name, workspace_id, skill_id, is_enabled, sort_order, config_json, version, updated_by, created_at, updated_at
|
||||
FROM role_skill_bindings
|
||||
WHERE role_name = ?
|
||||
ORDER BY sort_order, skill_id, workspace_id
|
||||
`, roleName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list role skill bindings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []role.SkillBinding
|
||||
for rows.Next() {
|
||||
item, err := scanRoleSkillBinding(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate role skill bindings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func getRoleTx(ctx context.Context, tx *sql.Tx, name string) (role.Definition, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT name, title, executor_kind, description, is_enabled, is_builtin, sort_order, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE name = ?
|
||||
`, name)
|
||||
return scanRole(row)
|
||||
}
|
||||
|
||||
func getRolePromptTx(ctx context.Context, tx *sql.Tx, roleName, workspaceID string, kind role.PromptKind) (role.Prompt, error) {
|
||||
var row *sql.Row
|
||||
if workspaceID == "" {
|
||||
row = tx.QueryRowContext(ctx, `
|
||||
SELECT id, role_name, workspace_id, prompt_kind, content_markdown, version, updated_by, created_at, updated_at
|
||||
FROM role_prompts
|
||||
WHERE role_name = ? AND prompt_kind = ? AND workspace_id IS NULL
|
||||
`, roleName, string(kind))
|
||||
} else {
|
||||
row = tx.QueryRowContext(ctx, `
|
||||
SELECT id, role_name, workspace_id, prompt_kind, content_markdown, version, updated_by, created_at, updated_at
|
||||
FROM role_prompts
|
||||
WHERE role_name = ? AND prompt_kind = ? AND workspace_id = ?
|
||||
`, roleName, string(kind), workspaceID)
|
||||
}
|
||||
return scanRolePrompt(row)
|
||||
}
|
||||
|
||||
func getRoleConfigTx(ctx context.Context, tx *sql.Tx, roleName string) (role.Config, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT name, config_toml, auth_json
|
||||
FROM roles
|
||||
WHERE name = ?
|
||||
`, roleName)
|
||||
return scanRoleConfig(row)
|
||||
}
|
||||
|
||||
func getRoleSkillBindingTx(ctx context.Context, tx *sql.Tx, roleName, workspaceID, skillID string) (role.SkillBinding, error) {
|
||||
var row *sql.Row
|
||||
if workspaceID == "" {
|
||||
row = tx.QueryRowContext(ctx, `
|
||||
SELECT id, role_name, workspace_id, skill_id, is_enabled, sort_order, config_json, version, updated_by, created_at, updated_at
|
||||
FROM role_skill_bindings
|
||||
WHERE role_name = ? AND skill_id = ? AND workspace_id IS NULL
|
||||
`, roleName, skillID)
|
||||
} else {
|
||||
row = tx.QueryRowContext(ctx, `
|
||||
SELECT id, role_name, workspace_id, skill_id, is_enabled, sort_order, config_json, version, updated_by, created_at, updated_at
|
||||
FROM role_skill_bindings
|
||||
WHERE role_name = ? AND skill_id = ? AND workspace_id = ?
|
||||
`, roleName, skillID, workspaceID)
|
||||
}
|
||||
return scanRoleSkillBinding(row)
|
||||
}
|
||||
|
||||
func scanRole(s scanner) (role.Definition, error) {
|
||||
var item role.Definition
|
||||
var executorKind string
|
||||
if err := s.Scan(&item.Name, &item.Title, &executorKind, &item.Description, &item.IsEnabled, &item.IsBuiltin, &item.SortOrder, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return role.Definition{}, err
|
||||
}
|
||||
item.ExecutorKind = role.ExecutorKind(executorKind)
|
||||
item = role.NormalizeDefinition(item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanRolePrompt(s scanner) (role.Prompt, error) {
|
||||
var item role.Prompt
|
||||
var workspaceID sql.NullString
|
||||
var kind string
|
||||
if err := s.Scan(&item.ID, &item.RoleName, &workspaceID, &kind, &item.ContentMarkdown, &item.Version, &item.UpdatedBy, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return role.Prompt{}, err
|
||||
}
|
||||
if workspaceID.Valid {
|
||||
item.WorkspaceID = workspaceID.String
|
||||
}
|
||||
item.PromptKind = role.PromptKind(kind)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanRoleConfig(s scanner) (role.Config, error) {
|
||||
var item role.Config
|
||||
if err := s.Scan(&item.RoleName, &item.ConfigTOML, &item.AuthJSON); err != nil {
|
||||
return role.Config{}, err
|
||||
}
|
||||
return role.NormalizeConfig(item), nil
|
||||
}
|
||||
|
||||
func scanRoleSkillBinding(s scanner) (role.SkillBinding, error) {
|
||||
var item role.SkillBinding
|
||||
var workspaceID sql.NullString
|
||||
var configJSON string
|
||||
if err := s.Scan(&item.ID, &item.RoleName, &workspaceID, &item.SkillID, &item.IsEnabled, &item.SortOrder, &configJSON, &item.Version, &item.UpdatedBy, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return role.SkillBinding{}, err
|
||||
}
|
||||
if workspaceID.Valid {
|
||||
item.WorkspaceID = workspaceID.String
|
||||
}
|
||||
if err := unmarshalJSON(configJSON, &item.Config); err != nil {
|
||||
return role.SkillBinding{}, fmt.Errorf("decode role skill binding config: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/message"
|
||||
"inbox/internal/domain/role"
|
||||
)
|
||||
|
||||
var errClaimConflict = fmt.Errorf("delivery claim conflict")
|
||||
|
||||
func (s *Store) GetMessage(ctx context.Context, messageID string) (message.Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, from_role_name, to_expr, type, stage, reply_to_message_id, body_markdown, created_at
|
||||
FROM messages
|
||||
WHERE id = ?
|
||||
`, messageID)
|
||||
return scanMessage(row)
|
||||
}
|
||||
|
||||
func (s *Store) ClaimNextDelivery(ctx context.Context, workspaceID string, roleNames []string, staleBefore string) (message.DeliveryClaim, error) {
|
||||
roleNames = normalizeRoleNames(roleNames)
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return message.DeliveryClaim{}, fmt.Errorf("begin claim delivery: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
item, err := s.claimNextDeliveryTx(ctx, tx, workspaceID, roleNames, staleBefore)
|
||||
if err == nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return message.DeliveryClaim{}, fmt.Errorf("commit claim delivery: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
return message.DeliveryClaim{}, err
|
||||
}
|
||||
if err == errClaimConflict {
|
||||
continue
|
||||
}
|
||||
return message.DeliveryClaim{}, err
|
||||
}
|
||||
return message.DeliveryClaim{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (s *Store) claimNextDeliveryTx(ctx context.Context, tx *sql.Tx, workspaceID string, roleNames []string, staleBefore string) (message.DeliveryClaim, error) {
|
||||
query := `
|
||||
SELECT m.id, m.workspace_id, m.topic_id, m.from_role_name, m.to_expr, m.type, m.stage, m.reply_to_message_id, m.body_markdown, m.created_at,
|
||||
d.recipient_role_name, d.state, d.updated_at
|
||||
FROM message_deliveries d
|
||||
JOIN messages m ON m.id = d.message_id
|
||||
JOIN roles r ON r.name = d.recipient_role_name
|
||||
WHERE m.workspace_id = ?
|
||||
AND r.executor_kind = ?
|
||||
AND (d.state = ? OR (d.state = ? AND d.updated_at < ?))
|
||||
`
|
||||
args := []any{workspaceID, string(role.ExecutorKindCodex), string(message.DeliveryPending), string(message.DeliveryReceived), staleBefore}
|
||||
if len(roleNames) > 0 {
|
||||
query += " AND d.recipient_role_name IN (" + placeholders(len(roleNames)) + ")"
|
||||
for _, roleName := range roleNames {
|
||||
args = append(args, roleName)
|
||||
}
|
||||
}
|
||||
query += `
|
||||
ORDER BY CASE d.state WHEN ? THEN 0 ELSE 1 END, d.updated_at, m.created_at, m.id
|
||||
LIMIT 1
|
||||
`
|
||||
args = append(args, string(message.DeliveryPending))
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, args...)
|
||||
|
||||
var item message.DeliveryClaim
|
||||
var msgType string
|
||||
var replyTo sql.NullString
|
||||
var state string
|
||||
if err := row.Scan(
|
||||
&item.Message.ID,
|
||||
&item.Message.WorkspaceID,
|
||||
&item.Message.TopicID,
|
||||
&item.Message.FromRoleName,
|
||||
&item.Message.ToExpr,
|
||||
&msgType,
|
||||
&item.Message.Stage,
|
||||
&replyTo,
|
||||
&item.Message.BodyMarkdown,
|
||||
&item.Message.CreatedAt,
|
||||
&item.RecipientRoleName,
|
||||
&state,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return message.DeliveryClaim{}, sql.ErrNoRows
|
||||
}
|
||||
return message.DeliveryClaim{}, fmt.Errorf("select claim delivery: %w", err)
|
||||
}
|
||||
item.Message.Type = message.Type(msgType)
|
||||
item.State = message.DeliveryState(state)
|
||||
if replyTo.Valid {
|
||||
item.Message.ReplyToMessageID = replyTo.String
|
||||
}
|
||||
|
||||
now := s.now()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE message_deliveries
|
||||
SET state = ?, updated_at = ?
|
||||
WHERE message_id = ? AND recipient_role_name = ? AND state = ? AND updated_at = ?
|
||||
`,
|
||||
string(message.DeliveryReceived),
|
||||
now,
|
||||
item.Message.ID,
|
||||
item.RecipientRoleName,
|
||||
string(item.State),
|
||||
item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return message.DeliveryClaim{}, fmt.Errorf("claim delivery: %w", err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil && rowsAffected == 0 {
|
||||
return message.DeliveryClaim{}, errClaimConflict
|
||||
}
|
||||
|
||||
item.State = message.DeliveryReceived
|
||||
item.UpdatedAt = now
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Store) TouchDelivery(ctx context.Context, messageID, roleName string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE message_deliveries
|
||||
SET updated_at = ?
|
||||
WHERE message_id = ? AND recipient_role_name = ? AND state = ?
|
||||
`, s.now(), messageID, roleName, string(message.DeliveryReceived)); err != nil {
|
||||
return fmt.Errorf("touch delivery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ArchiveDelivery(ctx context.Context, messageID, roleName string) error {
|
||||
now := s.now()
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE message_deliveries
|
||||
SET state = ?, updated_at = ?
|
||||
WHERE message_id = ? AND recipient_role_name = ?
|
||||
`, string(message.DeliveryArchived), now, messageID, roleName); err != nil {
|
||||
return fmt.Errorf("archive delivery: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ArchiveMessageDeliveries(ctx context.Context, messageID string) error {
|
||||
now := s.now()
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE message_deliveries
|
||||
SET state = ?, updated_at = ?
|
||||
WHERE message_id = ? AND state != ?
|
||||
`, string(message.DeliveryArchived), now, messageID, string(message.DeliveryArchived)); err != nil {
|
||||
return fmt.Errorf("archive message deliveries: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func placeholders(count int) string {
|
||||
items := make([]string, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
items = append(items, "?")
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
func normalizeRoleNames(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/skill"
|
||||
)
|
||||
|
||||
func (s *Store) UpsertSkill(ctx context.Context, value skill.Definition, changedBy string) (skill.Definition, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return skill.Definition{}, err
|
||||
}
|
||||
return upsertVersionedConfig(ctx, s, value, changedBy, versionedConfigUpsertSpec[skill.Definition]{
|
||||
entityName: "skill",
|
||||
idKind: "skill",
|
||||
loadTx: func(ctx context.Context, tx *sql.Tx) (skill.Definition, error) {
|
||||
return getSkillByKeyTx(ctx, tx, value.SkillKey)
|
||||
},
|
||||
current: func(item skill.Definition) versionedConfigCurrent {
|
||||
return versionedConfigCurrent{
|
||||
ID: item.ID,
|
||||
Version: item.Version,
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
},
|
||||
applyMetadata: func(item *skill.Definition, meta versionedConfigMetadata) {
|
||||
item.ID = meta.ID
|
||||
item.Version = meta.Version
|
||||
item.CreatedAt = meta.CreatedAt
|
||||
item.UpdatedAt = meta.UpdatedAt
|
||||
},
|
||||
writeTx: func(ctx context.Context, tx *sql.Tx, item skill.Definition) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO skills(id, skill_key, name, description, source_type, content_markdown, status, version, created_at, updated_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
source_type = excluded.source_type,
|
||||
content_markdown = excluded.content_markdown,
|
||||
status = excluded.status,
|
||||
version = excluded.version,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
item.ID,
|
||||
item.SkillKey,
|
||||
item.Name,
|
||||
item.Description,
|
||||
item.SourceType,
|
||||
item.ContentMarkdown,
|
||||
item.Status,
|
||||
item.Version,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ListSkillsByIDs(ctx context.Context, ids []string) (map[string]skill.Definition, error) {
|
||||
ids = slices.Clone(ids)
|
||||
if len(ids) == 0 {
|
||||
return map[string]skill.Definition{}, nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, skill_key, name, description, source_type, content_markdown, status, version, created_at, updated_at
|
||||
FROM skills
|
||||
WHERE id IN (%s)
|
||||
`, strings.Join(placeholders, ", "))
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list skills by ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]skill.Definition, len(ids))
|
||||
for rows.Next() {
|
||||
item, err := scanSkill(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[item.ID] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate skills: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSkillByKey(ctx context.Context, skillKey string) (skill.Definition, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, skill_key, name, description, source_type, content_markdown, status, version, created_at, updated_at
|
||||
FROM skills
|
||||
WHERE skill_key = ?
|
||||
`, skillKey)
|
||||
return scanSkill(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListSkills(ctx context.Context) ([]skill.Definition, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, skill_key, name, description, source_type, content_markdown, status, version, created_at, updated_at
|
||||
FROM skills
|
||||
ORDER BY name, skill_key
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list skills: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []skill.Definition
|
||||
for rows.Next() {
|
||||
item, err := scanSkill(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate skills: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSkillByKey(ctx context.Context, skillKey string) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM skills WHERE skill_key = ?`, skillKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete skill: %w", err)
|
||||
}
|
||||
return ensureAffected(result, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
func getSkillByKeyTx(ctx context.Context, tx *sql.Tx, skillKey string) (skill.Definition, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, skill_key, name, description, source_type, content_markdown, status, version, created_at, updated_at
|
||||
FROM skills
|
||||
WHERE skill_key = ?
|
||||
`, skillKey)
|
||||
return scanSkill(row)
|
||||
}
|
||||
|
||||
func scanSkill(s scanner) (skill.Definition, error) {
|
||||
var item skill.Definition
|
||||
if err := s.Scan(&item.ID, &item.SkillKey, &item.Name, &item.Description, &item.SourceType, &item.ContentMarkdown, &item.Status, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return skill.Definition{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
)
|
||||
|
||||
type migration struct {
|
||||
Version int
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
var migrations = mustLoadMigrations()
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
clock timeutil.Clock
|
||||
}
|
||||
|
||||
func Open(dbPath string, clock timeutil.Clock) (*Store, error) {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if err := configure(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := Migrate(context.Background(), db, clock); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{db: db, clock: clock}
|
||||
if err := store.ensureBuiltinRoles(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.ensureBuiltinSkills(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func OpenInMemory(clock timeutil.Clock) (*Store, error) {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite memory db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if err := configure(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := Migrate(context.Background(), db, clock); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{db: db, clock: clock}
|
||||
if err := store.ensureBuiltinRoles(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.ensureBuiltinSkills(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func New(db *sql.DB, clock timeutil.Clock) *Store {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
return &Store{db: db, clock: clock}
|
||||
}
|
||||
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func configure(db *sql.DB) error {
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return fmt.Errorf("enable WAL: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
return fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
||||
return fmt.Errorf("set busy timeout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, db *sql.DB, clock timeutil.Clock) error {
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
if err := ensureSchemaMigrationsTable(db); err != nil {
|
||||
return err
|
||||
}
|
||||
applied, err := loadAppliedVersions(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(migrations) == 0 {
|
||||
return nil
|
||||
}
|
||||
current := migrations[len(migrations)-1].Version
|
||||
for version := range applied {
|
||||
if version > current {
|
||||
return fmt.Errorf("database schema version %d is newer than supported version %d", version, current)
|
||||
}
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
if applied[migration.Version] {
|
||||
continue
|
||||
}
|
||||
if err := applyMigration(ctx, db, migration, clock); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureSchemaMigrationsTable(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAppliedVersions(ctx context.Context, db *sql.DB) (map[int]bool, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load schema migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[int]bool)
|
||||
for rows.Next() {
|
||||
var version int
|
||||
if err := rows.Scan(&version); err != nil {
|
||||
return nil, fmt.Errorf("scan schema migration version: %w", err)
|
||||
}
|
||||
applied[version] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate schema migrations: %w", err)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, db *sql.DB, m migration, clock timeutil.Clock) error {
|
||||
if m.Version == 12 {
|
||||
shouldApply, err := shouldApplyLaneRenameMigration(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preflight migration %s: %w", m.Name, err)
|
||||
}
|
||||
if !shouldApply {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, ?)`,
|
||||
m.Version, m.Name, timeutil.FormatRFC3339(clock.Now())); err != nil {
|
||||
return fmt.Errorf("record skipped migration %s: %w", m.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if m.Version == 32 {
|
||||
shouldApply, err := shouldApplyDropSkillMetadataColumnsMigration(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("preflight migration %s: %w", m.Name, err)
|
||||
}
|
||||
if !shouldApply {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, ?)`,
|
||||
m.Version, m.Name, timeutil.FormatRFC3339(clock.Now())); err != nil {
|
||||
return fmt.Errorf("record skipped migration %s: %w", m.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if m.Version == 30 {
|
||||
return applyRoleConfigCollapseMigration(ctx, db, m, clock)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", m.Name, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", m.Name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, ?)`,
|
||||
m.Version,
|
||||
m.Name,
|
||||
timeutil.FormatRFC3339(clock.Now()),
|
||||
); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", m.Name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", m.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldApplyDropSkillMetadataColumnsMigration(ctx context.Context, db *sql.DB) (bool, error) {
|
||||
rows, err := db.QueryContext(ctx, `PRAGMA table_info(skills)`)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect skills table: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
hasSourceRef := false
|
||||
hasAssetRoot := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name string
|
||||
var dataType string
|
||||
var notNull int
|
||||
var defaultValue sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return false, fmt.Errorf("scan skills table info: %w", err)
|
||||
}
|
||||
switch name {
|
||||
case "source_ref":
|
||||
hasSourceRef = true
|
||||
case "asset_root":
|
||||
hasAssetRoot = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, fmt.Errorf("iterate skills table info: %w", err)
|
||||
}
|
||||
return hasSourceRef || hasAssetRoot, nil
|
||||
}
|
||||
|
||||
func shouldApplyLaneRenameMigration(ctx context.Context, db *sql.DB) (bool, error) {
|
||||
hasChains, err := tableExists(ctx, db, "chains")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
hasLanes, err := tableExists(ctx, db, "lanes")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if hasChains {
|
||||
return true, nil
|
||||
}
|
||||
if hasLanes {
|
||||
return false, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func tableExists(ctx context.Context, db *sql.DB, name string) (bool, error) {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = ?
|
||||
`, name).Scan(&count); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func mustLoadMigrations() []migration {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("read migrations: %v", err))
|
||||
}
|
||||
|
||||
out := make([]migration, 0, len(entries))
|
||||
seen := make(map[int]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || path.Ext(entry.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(entry.Name(), "_", 2)
|
||||
if len(parts) != 2 {
|
||||
panic(fmt.Sprintf("migration file %q must start with a numeric prefix", entry.Name()))
|
||||
}
|
||||
version, err := strconv.Atoi(parts[0])
|
||||
if err != nil || version <= 0 {
|
||||
panic(fmt.Sprintf("migration file %q has invalid version prefix", entry.Name()))
|
||||
}
|
||||
if prior, exists := seen[version]; exists {
|
||||
panic(fmt.Sprintf("duplicate migration version %d: %q and %q", version, prior, entry.Name()))
|
||||
}
|
||||
body, err := migrationFiles.ReadFile(path.Join("migrations", entry.Name()))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("read migration %q: %v", entry.Name(), err))
|
||||
}
|
||||
seen[version] = entry.Name()
|
||||
out = append(out, migration{
|
||||
Version: version,
|
||||
Name: entry.Name(),
|
||||
SQL: string(body),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].Version < out[j].Version
|
||||
})
|
||||
for idx, item := range out {
|
||||
expected := idx + 1
|
||||
if item.Version != expected {
|
||||
panic(fmt.Sprintf("migrations must be contiguous: expected version %d, found %d (%s)", expected, item.Version, item.Name))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package sqlite
|
||||
|
||||
import "database/sql"
|
||||
|
||||
func ensureAffected(result sql.Result, fallback error) error {
|
||||
if result == nil {
|
||||
return fallback
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if rows == 0 {
|
||||
return fallback
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/taskgraph"
|
||||
)
|
||||
|
||||
func (s *Store) CreateTaskGraphVersion(ctx context.Context, value taskgraph.Record) (taskgraph.Record, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return taskgraph.Record{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("task-graph-version")
|
||||
if err != nil {
|
||||
return taskgraph.Record{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, s.now())
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO task_graph_versions(
|
||||
id, topic_id, version, status, plan_json, plan_summary_markdown,
|
||||
created_by_role_name, created_at, confirmed_at, supersedes_graph_version_id
|
||||
)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
value.ID,
|
||||
value.TopicID,
|
||||
value.Version,
|
||||
string(value.Status),
|
||||
value.PlanJSON,
|
||||
value.PlanSummaryMarkdown,
|
||||
value.CreatedByRoleName,
|
||||
value.CreatedAt,
|
||||
nullableString(value.ConfirmedAt),
|
||||
nullableString(value.SupersedesGraphVersionID),
|
||||
); err != nil {
|
||||
return taskgraph.Record{}, fmt.Errorf("create task graph version: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTaskGraphVersion(ctx context.Context, graphVersionID string) (taskgraph.Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, topic_id, version, status, plan_json, plan_summary_markdown,
|
||||
created_by_role_name, created_at, confirmed_at, supersedes_graph_version_id
|
||||
FROM task_graph_versions
|
||||
WHERE id = ?
|
||||
`, graphVersionID)
|
||||
return scanTaskGraphVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListTaskGraphVersionsByTopic(ctx context.Context, topicID string) ([]taskgraph.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, topic_id, version, status, plan_json, plan_summary_markdown,
|
||||
created_by_role_name, created_at, confirmed_at, supersedes_graph_version_id
|
||||
FROM task_graph_versions
|
||||
WHERE topic_id = ?
|
||||
ORDER BY version DESC, created_at DESC
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list task graph versions by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []taskgraph.Record
|
||||
for rows.Next() {
|
||||
item, err := scanTaskGraphVersion(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate task graph versions: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetLatestTaskGraphVersionByTopic(ctx context.Context, topicID string) (taskgraph.Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, topic_id, version, status, plan_json, plan_summary_markdown,
|
||||
created_by_role_name, created_at, confirmed_at, supersedes_graph_version_id
|
||||
FROM task_graph_versions
|
||||
WHERE topic_id = ?
|
||||
ORDER BY version DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`, topicID)
|
||||
return scanTaskGraphVersion(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTaskGraphVersion(ctx context.Context, value taskgraph.Record) (taskgraph.Record, error) {
|
||||
if value.ID == "" {
|
||||
return taskgraph.Record{}, fmt.Errorf("task graph version id is required")
|
||||
}
|
||||
before, err := s.GetTaskGraphVersion(ctx, value.ID)
|
||||
if err != nil {
|
||||
return taskgraph.Record{}, err
|
||||
}
|
||||
value.TopicID = before.TopicID
|
||||
value.Version = before.Version
|
||||
value.CreatedAt = before.CreatedAt
|
||||
if err := value.Validate(); err != nil {
|
||||
return taskgraph.Record{}, err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE task_graph_versions
|
||||
SET status = ?, plan_json = ?, plan_summary_markdown = ?, created_by_role_name = ?,
|
||||
confirmed_at = ?, supersedes_graph_version_id = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
string(value.Status),
|
||||
value.PlanJSON,
|
||||
value.PlanSummaryMarkdown,
|
||||
value.CreatedByRoleName,
|
||||
nullableString(value.ConfirmedAt),
|
||||
nullableString(value.SupersedesGraphVersionID),
|
||||
value.ID,
|
||||
); err != nil {
|
||||
return taskgraph.Record{}, fmt.Errorf("update task graph version: %w", err)
|
||||
}
|
||||
return s.GetTaskGraphVersion(ctx, value.ID)
|
||||
}
|
||||
|
||||
func scanTaskGraphVersion(s scanner) (taskgraph.Record, error) {
|
||||
var item taskgraph.Record
|
||||
var status string
|
||||
var confirmedAt sql.NullString
|
||||
var supersedesID sql.NullString
|
||||
if err := s.Scan(
|
||||
&item.ID,
|
||||
&item.TopicID,
|
||||
&item.Version,
|
||||
&status,
|
||||
&item.PlanJSON,
|
||||
&item.PlanSummaryMarkdown,
|
||||
&item.CreatedByRoleName,
|
||||
&item.CreatedAt,
|
||||
&confirmedAt,
|
||||
&supersedesID,
|
||||
); err != nil {
|
||||
return taskgraph.Record{}, err
|
||||
}
|
||||
item.Status = taskgraph.Status(status)
|
||||
if confirmedAt.Valid {
|
||||
item.ConfirmedAt = confirmedAt.String
|
||||
}
|
||||
if supersedesID.Valid {
|
||||
item.SupersedesGraphVersionID = supersedesID.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/lane"
|
||||
"inbox/internal/domain/task"
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
func (s *Store) ClaimTaskExecution(ctx context.Context, run workflow.Run, taskID, startedAt string) (workflow.Run, task.Record, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, fmt.Errorf("begin claim task execution: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
taskRecord, err := getTaskTx(ctx, tx, taskID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, err
|
||||
}
|
||||
run.StartedAt = startedAt
|
||||
if err := createWorkflowRunTx(ctx, tx, s, &run); err != nil {
|
||||
return workflow.Run{}, task.Record{}, err
|
||||
}
|
||||
|
||||
taskRecord.Status = task.StatusRunning
|
||||
taskRecord.AssignedRunID = run.ID
|
||||
taskRecord.StartedAt = startedAt
|
||||
taskRecord.UpdatedAt = startedAt
|
||||
if err := updateTaskTx(ctx, tx, taskRecord); err != nil {
|
||||
return workflow.Run{}, task.Record{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return workflow.Run{}, task.Record{}, fmt.Errorf("commit claim task execution: %w", err)
|
||||
}
|
||||
claimedRun, err := s.GetWorkflowRun(ctx, run.ID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, err
|
||||
}
|
||||
return claimedRun, taskRecord, nil
|
||||
}
|
||||
|
||||
func (s *Store) CompleteTaskExecution(
|
||||
ctx context.Context,
|
||||
runID, taskID, laneID string,
|
||||
status workflow.RunStatus,
|
||||
exitCode int,
|
||||
resultMarkdown, errorMessage, completedAt string,
|
||||
) (workflow.Run, task.Record, lane.Record, []task.Record, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, fmt.Errorf("begin complete task execution: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
run, err := getWorkflowRunTx(ctx, tx, runID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
taskRecord, err := getTaskTx(ctx, tx, taskID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
laneRecord, err := getLaneTx(ctx, tx, laneID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
|
||||
if status == workflow.RunStatusSucceeded {
|
||||
taskRecord.Status = task.StatusSucceeded
|
||||
taskRecord.ResultSummaryMarkdown = resultMarkdown
|
||||
taskRecord.BlockingReasonMarkdown = ""
|
||||
} else {
|
||||
taskRecord.Status = task.StatusFailed
|
||||
taskRecord.ResultSummaryMarkdown = ""
|
||||
taskRecord.BlockingReasonMarkdown = errorMessage
|
||||
}
|
||||
taskRecord.AssignedRunID = run.ID
|
||||
taskRecord.CompletedAt = completedAt
|
||||
taskRecord.UpdatedAt = completedAt
|
||||
if err := updateTaskTx(ctx, tx, taskRecord); err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
|
||||
promotedTasks, touchedLaneIDs, err := promoteReadyTasksTx(ctx, tx, run.TopicID, completedAt)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
touchedLaneIDs[laneID] = struct{}{}
|
||||
updatedLanes, err := refreshTopicLaneStatusesTx(ctx, tx, run.TopicID, touchedLaneIDs, completedAt)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
if updated, ok := updatedLanes[laneID]; ok {
|
||||
laneRecord = updated
|
||||
}
|
||||
|
||||
run.Status = status
|
||||
run.ExitCode = exitCode
|
||||
run.CompletedAt = completedAt
|
||||
run.ErrorMessage = errorMessage
|
||||
if err := updateWorkflowRunTx(ctx, tx, run); err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, fmt.Errorf("commit complete task execution: %w", err)
|
||||
}
|
||||
updatedRun, err := s.GetWorkflowRun(ctx, run.ID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, task.Record{}, lane.Record{}, nil, err
|
||||
}
|
||||
return updatedRun, taskRecord, laneRecord, promotedTasks, nil
|
||||
}
|
||||
|
||||
func getLaneTx(ctx context.Context, tx *sql.Tx, laneID string) (lane.Record, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, name, slug, purpose, status, base_branch, branch_name, head_commit, worktree_path,
|
||||
container_name, runtime_endpoint, created_by_role_name, result_summary_markdown, error_message,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM lanes
|
||||
WHERE id = ?
|
||||
`, laneID)
|
||||
return scanLane(row)
|
||||
}
|
||||
|
||||
func updateLaneTx(ctx context.Context, tx *sql.Tx, value lane.Record) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE lanes
|
||||
SET workspace_id = ?, topic_id = ?, name = ?, slug = ?, purpose = ?, status = ?, base_branch = ?, branch_name = ?, head_commit = ?,
|
||||
worktree_path = ?, container_name = ?, runtime_endpoint = ?, created_by_role_name = ?,
|
||||
result_summary_markdown = ?, error_message = ?, updated_at = ?, started_at = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.Name,
|
||||
value.Slug,
|
||||
value.Purpose,
|
||||
string(value.Status),
|
||||
value.BaseBranch,
|
||||
value.BranchName,
|
||||
value.HeadCommit,
|
||||
value.WorktreePath,
|
||||
value.ContainerName,
|
||||
value.RuntimeEndpoint,
|
||||
value.CreatedByRoleName,
|
||||
value.ResultSummaryMarkdown,
|
||||
value.ErrorMessage,
|
||||
value.UpdatedAt,
|
||||
nullableString(value.StartedAt),
|
||||
nullableString(value.CompletedAt),
|
||||
value.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update lane: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getWorkflowRunTx(ctx context.Context, tx *sql.Tx, runID string) (workflow.Run, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, stage, mode, status, request_message_id,
|
||||
config_snapshot_json, command_json, reply_message_id, exit_code, started_at, completed_at, error_message
|
||||
FROM workflow_runs
|
||||
WHERE id = ?
|
||||
`, runID)
|
||||
return scanWorkflowRun(row)
|
||||
}
|
||||
|
||||
func createWorkflowRunTx(ctx context.Context, tx *sql.Tx, s *Store, value *workflow.Run) error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("workflow-run")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
value.StartedAt = coalesceString(value.StartedAt, s.now())
|
||||
if value.CommandJSON == "" {
|
||||
value.CommandJSON = "[]"
|
||||
}
|
||||
if value.ConfigSnapshotJSON == "" {
|
||||
value.ConfigSnapshotJSON = "{}"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO workflow_runs(id, workspace_id, topic_id, role_name, stage, mode, status, request_message_id, config_snapshot_json, command_json, reply_message_id, exit_code, started_at, completed_at, error_message)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
value.ID,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.RoleName,
|
||||
string(value.Stage),
|
||||
value.Mode,
|
||||
string(value.Status),
|
||||
nullableString(value.RequestMessageID),
|
||||
value.ConfigSnapshotJSON,
|
||||
value.CommandJSON,
|
||||
nullableString(value.ReplyMessageID),
|
||||
value.ExitCode,
|
||||
value.StartedAt,
|
||||
nullableString(value.CompletedAt),
|
||||
value.ErrorMessage,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create workflow run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateWorkflowRunTx(ctx context.Context, tx *sql.Tx, value workflow.Run) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE workflow_runs
|
||||
SET workspace_id = ?, topic_id = ?, role_name = ?, stage = ?, mode = ?, status = ?, request_message_id = ?,
|
||||
config_snapshot_json = ?, command_json = ?, reply_message_id = ?,
|
||||
exit_code = ?, completed_at = ?, error_message = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.RoleName,
|
||||
string(value.Stage),
|
||||
value.Mode,
|
||||
string(value.Status),
|
||||
nullableString(value.RequestMessageID),
|
||||
value.ConfigSnapshotJSON,
|
||||
value.CommandJSON,
|
||||
nullableString(value.ReplyMessageID),
|
||||
value.ExitCode,
|
||||
nullableString(value.CompletedAt),
|
||||
value.ErrorMessage,
|
||||
value.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update workflow run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promoteReadyTasksTx(ctx context.Context, tx *sql.Tx, topicID, now string) ([]task.Record, map[string]struct{}, error) {
|
||||
items, err := listTasksByTopicTx(ctx, tx, topicID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
promoted := make([]task.Record, 0)
|
||||
touchedLanes := map[string]struct{}{}
|
||||
byLane := make(map[string][]task.Record)
|
||||
for _, item := range items {
|
||||
byLane[item.LaneID] = append(byLane[item.LaneID], item)
|
||||
}
|
||||
for {
|
||||
changed := false
|
||||
for _, laneItems := range byLane {
|
||||
if hasRunningTaskTx(laneItems) {
|
||||
continue
|
||||
}
|
||||
for idx, item := range laneItems {
|
||||
if item.Status != task.StatusDraft {
|
||||
continue
|
||||
}
|
||||
ready, err := dependenciesSatisfiedTx(ctx, tx, item.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
if item.Kind == task.KindMilestone {
|
||||
item.Status = task.StatusSucceeded
|
||||
item.ResultSummaryMarkdown = "Milestone completed automatically after dependencies succeeded."
|
||||
item.BlockingReasonMarkdown = ""
|
||||
item.UpdatedAt = now
|
||||
item.CompletedAt = now
|
||||
} else {
|
||||
item.Status = task.StatusReady
|
||||
item.UpdatedAt = now
|
||||
promoted = append(promoted, item)
|
||||
}
|
||||
if err := updateTaskTx(ctx, tx, item); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
laneItems[idx] = item
|
||||
byLane[item.LaneID][idx] = item
|
||||
touchedLanes[item.LaneID] = struct{}{}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
return promoted, touchedLanes, nil
|
||||
}
|
||||
|
||||
func refreshLaneStatusTx(ctx context.Context, tx *sql.Tx, laneID, now string) (lane.Record, error) {
|
||||
laneRecord, err := getLaneTx(ctx, tx, laneID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
items, err := listTasksByLaneTx(ctx, tx, laneID)
|
||||
if err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
|
||||
status := lane.StatusReady
|
||||
if len(items) == 0 {
|
||||
status = lane.StatusDraft
|
||||
}
|
||||
allSucceeded := len(items) > 0
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case task.StatusFailed:
|
||||
status = lane.StatusBlocked
|
||||
allSucceeded = false
|
||||
case task.StatusRunning:
|
||||
status = lane.StatusRunning
|
||||
allSucceeded = false
|
||||
case task.StatusBlocked:
|
||||
status = lane.StatusBlocked
|
||||
allSucceeded = false
|
||||
case task.StatusDraft, task.StatusReady:
|
||||
if status != lane.StatusBlocked && status != lane.StatusRunning {
|
||||
status = lane.StatusReady
|
||||
}
|
||||
allSucceeded = false
|
||||
case task.StatusCancelled:
|
||||
allSucceeded = false
|
||||
}
|
||||
}
|
||||
if allSucceeded {
|
||||
status = lane.StatusSucceeded
|
||||
laneRecord.CompletedAt = now
|
||||
}
|
||||
laneRecord.Status = status
|
||||
if status == lane.StatusRunning && laneRecord.StartedAt == "" {
|
||||
laneRecord.StartedAt = now
|
||||
}
|
||||
laneRecord.UpdatedAt = now
|
||||
if err := updateLaneTx(ctx, tx, laneRecord); err != nil {
|
||||
return lane.Record{}, err
|
||||
}
|
||||
return laneRecord, nil
|
||||
}
|
||||
|
||||
func refreshTopicLaneStatusesTx(ctx context.Context, tx *sql.Tx, topicID string, laneIDs map[string]struct{}, now string) (map[string]lane.Record, error) {
|
||||
out := make(map[string]lane.Record, len(laneIDs))
|
||||
if len(laneIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
for laneID := range laneIDs {
|
||||
item, err := refreshLaneStatusTx(ctx, tx, laneID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[laneID] = item
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func listTasksByLaneTx(ctx context.Context, tx *sql.Tx, laneID string) ([]task.Record, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, lane_id, title, body_markdown, acceptance_markdown, task_kind, deliverables_json, batch_key, status, priority,
|
||||
task_order, created_by_role_name, blocking_reason_markdown, result_summary_markdown, assigned_run_id,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM tasks
|
||||
WHERE lane_id = ?
|
||||
ORDER BY task_order, priority DESC, created_at, id
|
||||
`, laneID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks by lane: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []task.Record
|
||||
for rows.Next() {
|
||||
item, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tasks by lane: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func listTasksByTopicTx(ctx context.Context, tx *sql.Tx, topicID string) ([]task.Record, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, lane_id, title, body_markdown, acceptance_markdown, task_kind, deliverables_json, batch_key, status, priority,
|
||||
task_order, created_by_role_name, blocking_reason_markdown, result_summary_markdown, assigned_run_id,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM tasks
|
||||
WHERE topic_id = ?
|
||||
ORDER BY lane_id, task_order, priority DESC, created_at, id
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []task.Record
|
||||
for rows.Next() {
|
||||
item, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tasks by topic: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dependenciesSatisfiedTx(ctx context.Context, tx *sql.Tx, taskID string) (bool, error) {
|
||||
deps, err := listTaskDependenciesTx(ctx, tx, taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, dep := range deps {
|
||||
item, err := getTaskTx(ctx, tx, dep.DependsOnTaskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if item.Status != task.StatusSucceeded {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func listTaskDependenciesTx(ctx context.Context, tx *sql.Tx, taskID string) ([]task.Dependency, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT task_id, depends_on_task_id
|
||||
FROM task_dependencies
|
||||
WHERE task_id = ?
|
||||
ORDER BY depends_on_task_id
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list task dependencies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []task.Dependency
|
||||
for rows.Next() {
|
||||
var item task.Dependency
|
||||
if err := rows.Scan(&item.TaskID, &item.DependsOnTaskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate task dependencies: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasRunningTaskTx(items []task.Record) bool {
|
||||
for _, item := range items {
|
||||
if item.Status == task.StatusRunning {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/task"
|
||||
)
|
||||
|
||||
func (s *Store) CreateTask(ctx context.Context, value task.Record, dependencies []task.Dependency) (task.Record, error) {
|
||||
value = task.NormalizeRecord(value)
|
||||
if err := value.Validate(); err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return task.Record{}, fmt.Errorf("begin create task: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("task")
|
||||
if err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
now := s.now()
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, now)
|
||||
value.UpdatedAt = now
|
||||
for idx := range dependencies {
|
||||
dependencies[idx].TaskID = value.ID
|
||||
if err := dependencies[idx].Validate(); err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tasks(
|
||||
id, workspace_id, topic_id, lane_id, title, body_markdown, acceptance_markdown, task_kind, deliverables_json, batch_key, status, priority,
|
||||
task_order, created_by_role_name, blocking_reason_markdown, result_summary_markdown, assigned_run_id,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
value.ID,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.LaneID,
|
||||
value.Title,
|
||||
value.BodyMarkdown,
|
||||
value.AcceptanceMarkdown,
|
||||
string(value.Kind),
|
||||
mustMarshalStringSlice(value.Deliverables),
|
||||
value.BatchKey,
|
||||
string(value.Status),
|
||||
value.Priority,
|
||||
value.TaskOrder,
|
||||
value.CreatedByRoleName,
|
||||
value.BlockingReasonMarkdown,
|
||||
value.ResultSummaryMarkdown,
|
||||
nullableString(value.AssignedRunID),
|
||||
value.CreatedAt,
|
||||
value.UpdatedAt,
|
||||
nullableString(value.StartedAt),
|
||||
nullableString(value.CompletedAt),
|
||||
); err != nil {
|
||||
return task.Record{}, fmt.Errorf("insert task: %w", err)
|
||||
}
|
||||
|
||||
for _, dep := range dependencies {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO task_dependencies(task_id, depends_on_task_id)
|
||||
VALUES(?, ?)
|
||||
`, value.ID, dep.DependsOnTaskID); err != nil {
|
||||
return task.Record{}, fmt.Errorf("insert task dependency: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return task.Record{}, fmt.Errorf("commit create task: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTask(ctx context.Context, taskID string) (task.Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, lane_id, title, body_markdown, acceptance_markdown, task_kind, deliverables_json, batch_key, status, priority,
|
||||
task_order, created_by_role_name, blocking_reason_markdown, result_summary_markdown, assigned_run_id,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM tasks
|
||||
WHERE id = ?
|
||||
`, taskID)
|
||||
return scanTask(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListTasksByTopic(ctx context.Context, topicID string) ([]task.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, lane_id, title, body_markdown, acceptance_markdown, task_kind, deliverables_json, batch_key, status, priority,
|
||||
task_order, created_by_role_name, blocking_reason_markdown, result_summary_markdown, assigned_run_id,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM tasks
|
||||
WHERE topic_id = ?
|
||||
ORDER BY priority DESC, task_order, created_at, id
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []task.Record
|
||||
for rows.Next() {
|
||||
item, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tasks by topic: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTasksByLane(ctx context.Context, laneID string) ([]task.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, lane_id, title, body_markdown, acceptance_markdown, task_kind, deliverables_json, batch_key, status, priority,
|
||||
task_order, created_by_role_name, blocking_reason_markdown, result_summary_markdown, assigned_run_id,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM tasks
|
||||
WHERE lane_id = ?
|
||||
ORDER BY task_order, priority DESC, created_at, id
|
||||
`, laneID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks by lane: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []task.Record
|
||||
for rows.Next() {
|
||||
item, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate tasks by lane: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTask(ctx context.Context, value task.Record) (task.Record, error) {
|
||||
return s.UpdateTaskWithDependencies(ctx, value, nil)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTaskWithDependencies(ctx context.Context, value task.Record, dependencies *[]task.Dependency) (task.Record, error) {
|
||||
if value.ID == "" {
|
||||
return task.Record{}, fmt.Errorf("task id is required")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return task.Record{}, fmt.Errorf("begin update task: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
before, err := getTaskTx(ctx, tx, value.ID)
|
||||
if err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
value.CreatedAt = before.CreatedAt
|
||||
value.UpdatedAt = s.now()
|
||||
value = task.NormalizeRecord(value)
|
||||
if err := value.Validate(); err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
if err := updateTaskTx(ctx, tx, value); err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
if dependencies != nil {
|
||||
if err := replaceTaskDependenciesTx(ctx, tx, value.ID, *dependencies); err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return task.Record{}, fmt.Errorf("commit update task: %w", err)
|
||||
}
|
||||
return s.GetTask(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceTaskDependencies(ctx context.Context, taskID string, dependencies []task.Dependency) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin replace task dependencies: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := replaceTaskDependenciesTx(ctx, tx, taskID, dependencies); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit replace task dependencies: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTaskDependencies(ctx context.Context, taskID string) ([]task.Dependency, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT task_id, depends_on_task_id
|
||||
FROM task_dependencies
|
||||
WHERE task_id = ?
|
||||
ORDER BY depends_on_task_id
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list task dependencies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []task.Dependency
|
||||
for rows.Next() {
|
||||
var item task.Dependency
|
||||
if err := rows.Scan(&item.TaskID, &item.DependsOnTaskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate task dependencies: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) AppendTaskEvent(ctx context.Context, value task.Event) (task.Event, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return task.Event{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("task-event")
|
||||
if err != nil {
|
||||
return task.Event{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, s.now())
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO task_events(id, task_id, event_type, body_markdown, created_by_role_name, created_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?)
|
||||
`, value.ID, value.TaskID, value.EventType, value.BodyMarkdown, value.CreatedByRoleName, value.CreatedAt); err != nil {
|
||||
return task.Event{}, fmt.Errorf("append task event: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTaskEvents(ctx context.Context, taskID string) ([]task.Event, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, task_id, event_type, body_markdown, created_by_role_name, created_at
|
||||
FROM task_events
|
||||
WHERE task_id = ?
|
||||
ORDER BY created_at, id
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list task events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []task.Event
|
||||
for rows.Next() {
|
||||
var item task.Event
|
||||
if err := rows.Scan(&item.ID, &item.TaskID, &item.EventType, &item.BodyMarkdown, &item.CreatedByRoleName, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate task events: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func getTaskTx(ctx context.Context, tx *sql.Tx, taskID string) (task.Record, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, lane_id, title, body_markdown, acceptance_markdown, task_kind, deliverables_json, batch_key, status, priority,
|
||||
task_order, created_by_role_name, blocking_reason_markdown, result_summary_markdown, assigned_run_id,
|
||||
created_at, updated_at, started_at, completed_at
|
||||
FROM tasks
|
||||
WHERE id = ?
|
||||
`, taskID)
|
||||
return scanTask(row)
|
||||
}
|
||||
|
||||
func updateTaskTx(ctx context.Context, tx *sql.Tx, value task.Record) error {
|
||||
value = task.NormalizeRecord(value)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE tasks
|
||||
SET workspace_id = ?, topic_id = ?, lane_id = ?, title = ?, body_markdown = ?, acceptance_markdown = ?,
|
||||
task_kind = ?, deliverables_json = ?, batch_key = ?, status = ?, priority = ?, task_order = ?, created_by_role_name = ?, blocking_reason_markdown = ?,
|
||||
result_summary_markdown = ?, assigned_run_id = ?, updated_at = ?, started_at = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.LaneID,
|
||||
value.Title,
|
||||
value.BodyMarkdown,
|
||||
value.AcceptanceMarkdown,
|
||||
string(value.Kind),
|
||||
mustMarshalStringSlice(value.Deliverables),
|
||||
value.BatchKey,
|
||||
string(value.Status),
|
||||
value.Priority,
|
||||
value.TaskOrder,
|
||||
value.CreatedByRoleName,
|
||||
value.BlockingReasonMarkdown,
|
||||
value.ResultSummaryMarkdown,
|
||||
nullableString(value.AssignedRunID),
|
||||
value.UpdatedAt,
|
||||
nullableString(value.StartedAt),
|
||||
nullableString(value.CompletedAt),
|
||||
value.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceTaskDependenciesTx(ctx context.Context, tx *sql.Tx, taskID string, dependencies []task.Dependency) error {
|
||||
for _, dep := range dependencies {
|
||||
if err := dep.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM task_dependencies WHERE task_id = ?`, taskID); err != nil {
|
||||
return fmt.Errorf("delete task dependencies: %w", err)
|
||||
}
|
||||
for _, dep := range dependencies {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO task_dependencies(task_id, depends_on_task_id)
|
||||
VALUES(?, ?)
|
||||
`, taskID, dep.DependsOnTaskID); err != nil {
|
||||
return fmt.Errorf("insert task dependency: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanTask(s scanner) (task.Record, error) {
|
||||
var item task.Record
|
||||
var kind string
|
||||
var deliverablesJSON string
|
||||
var status string
|
||||
var assignedRunID sql.NullString
|
||||
var startedAt sql.NullString
|
||||
var completedAt sql.NullString
|
||||
if err := s.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceID,
|
||||
&item.TopicID,
|
||||
&item.LaneID,
|
||||
&item.Title,
|
||||
&item.BodyMarkdown,
|
||||
&item.AcceptanceMarkdown,
|
||||
&kind,
|
||||
&deliverablesJSON,
|
||||
&item.BatchKey,
|
||||
&status,
|
||||
&item.Priority,
|
||||
&item.TaskOrder,
|
||||
&item.CreatedByRoleName,
|
||||
&item.BlockingReasonMarkdown,
|
||||
&item.ResultSummaryMarkdown,
|
||||
&assignedRunID,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&startedAt,
|
||||
&completedAt,
|
||||
); err != nil {
|
||||
return task.Record{}, err
|
||||
}
|
||||
item.Kind = task.Kind(kind)
|
||||
item.Deliverables = mustUnmarshalStringSlice(deliverablesJSON)
|
||||
item.Status = task.Status(status)
|
||||
if assignedRunID.Valid {
|
||||
item.AssignedRunID = assignedRunID.String
|
||||
}
|
||||
if startedAt.Valid {
|
||||
item.StartedAt = startedAt.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
item.CompletedAt = completedAt.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func mustMarshalStringSlice(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
data, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mustUnmarshalStringSlice(raw string) []string {
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
var items []string
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"inbox/internal/domain/topic"
|
||||
)
|
||||
|
||||
func (s *Store) CreateTopic(ctx context.Context, value topic.Record) (topic.Record, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("topic")
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
now := s.now()
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, now)
|
||||
value.UpdatedAt = now
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO topics(id, workspace_id, slug, title, space, status, summary, created_at, updated_at, closed_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, value.ID, value.WorkspaceID, value.Slug, value.Title, string(value.Space), value.Status, value.Summary, value.CreatedAt, value.UpdatedAt, nullableString(value.ClosedAt)); err != nil {
|
||||
return topic.Record{}, fmt.Errorf("create topic: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTopics(ctx context.Context, workspaceID string) ([]topic.Record, error) {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(workspaceID) == "" {
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, slug, title, space, status, summary, created_at, updated_at, closed_at
|
||||
FROM topics
|
||||
ORDER BY updated_at DESC, created_at DESC, slug
|
||||
`)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, slug, title, space, status, summary, created_at, updated_at, closed_at
|
||||
FROM topics
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY updated_at DESC, created_at DESC, slug
|
||||
`, workspaceID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list topics: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []topic.Record
|
||||
for rows.Next() {
|
||||
item, err := scanTopic(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate topics: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTopicsBySpace(ctx context.Context, workspaceID string, space topic.Space) ([]topic.Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, slug, title, space, status, summary, created_at, updated_at, closed_at
|
||||
FROM topics
|
||||
WHERE workspace_id = ? AND space = ?
|
||||
ORDER BY updated_at DESC, created_at DESC, slug
|
||||
`, workspaceID, string(space))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list topics by space: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []topic.Record
|
||||
for rows.Next() {
|
||||
item, err := scanTopic(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate topics by space: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTopic(ctx context.Context, topicID string) (topic.Record, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, slug, title, space, status, summary, created_at, updated_at, closed_at
|
||||
FROM topics
|
||||
WHERE id = ?
|
||||
`, topicID)
|
||||
return scanTopic(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetTopicBySlugOrTitle(ctx context.Context, workspaceID, value string, spaces ...topic.Space) (topic.Record, error) {
|
||||
args := []any{workspaceID, value, value}
|
||||
query := `
|
||||
SELECT id, workspace_id, slug, title, space, status, summary, created_at, updated_at, closed_at
|
||||
FROM topics
|
||||
WHERE workspace_id = ? AND (slug = ? OR title = ?)
|
||||
`
|
||||
if len(spaces) > 0 {
|
||||
query += " AND space IN (" + placeholders(len(spaces)) + ")"
|
||||
for _, space := range spaces {
|
||||
args = append(args, string(space))
|
||||
}
|
||||
}
|
||||
query += `
|
||||
ORDER BY CASE WHEN slug = ? THEN 0 ELSE 1 END, updated_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
args = append(args, value)
|
||||
row := s.db.QueryRowContext(ctx, query, args...)
|
||||
return scanTopic(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTopic(ctx context.Context, value topic.Record) (topic.Record, error) {
|
||||
if value.ID == "" {
|
||||
return topic.Record{}, fmt.Errorf("topic id is required")
|
||||
}
|
||||
current, err := s.GetTopic(ctx, value.ID)
|
||||
if err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
value.CreatedAt = current.CreatedAt
|
||||
value.UpdatedAt = s.now()
|
||||
if err := value.Validate(); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE topics
|
||||
SET workspace_id = ?, slug = ?, title = ?, space = ?, status = ?, summary = ?, updated_at = ?, closed_at = ?
|
||||
WHERE id = ?
|
||||
`, value.WorkspaceID, value.Slug, value.Title, string(value.Space), value.Status, value.Summary, value.UpdatedAt, nullableString(value.ClosedAt), value.ID)
|
||||
if err != nil {
|
||||
return topic.Record{}, fmt.Errorf("update topic: %w", err)
|
||||
}
|
||||
if err := ensureAffected(result, sql.ErrNoRows); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
return s.GetTopic(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTopic(ctx context.Context, topicID string) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id = ?`, topicID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete topic: %w", err)
|
||||
}
|
||||
return ensureAffected(result, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
func scanTopic(s scanner) (topic.Record, error) {
|
||||
var item topic.Record
|
||||
var space string
|
||||
var closedAt sql.NullString
|
||||
if err := s.Scan(&item.ID, &item.WorkspaceID, &item.Slug, &item.Title, &space, &item.Status, &item.Summary, &item.CreatedAt, &item.UpdatedAt, &closedAt); err != nil {
|
||||
return topic.Record{}, err
|
||||
}
|
||||
item.Space = topic.Space(space)
|
||||
if closedAt.Valid {
|
||||
item.ClosedAt = closedAt.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"inbox/internal/domain/workflow"
|
||||
)
|
||||
|
||||
func (s *Store) CreateWorkflowRun(ctx context.Context, value workflow.Run) (workflow.Run, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
if value.ID == "" {
|
||||
id, err := s.newID("workflow-run")
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
value.ID = id
|
||||
}
|
||||
value.StartedAt = coalesceString(value.StartedAt, s.now())
|
||||
if value.CommandJSON == "" {
|
||||
value.CommandJSON = "[]"
|
||||
}
|
||||
if value.ConfigSnapshotJSON == "" {
|
||||
value.ConfigSnapshotJSON = "{}"
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO workflow_runs(id, workspace_id, topic_id, role_name, stage, mode, status, request_message_id, config_snapshot_json, command_json, reply_message_id, exit_code, started_at, completed_at, error_message)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
value.ID,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.RoleName,
|
||||
string(value.Stage),
|
||||
value.Mode,
|
||||
string(value.Status),
|
||||
nullableString(value.RequestMessageID),
|
||||
value.ConfigSnapshotJSON,
|
||||
value.CommandJSON,
|
||||
nullableString(value.ReplyMessageID),
|
||||
value.ExitCode,
|
||||
value.StartedAt,
|
||||
nullableString(value.CompletedAt),
|
||||
value.ErrorMessage,
|
||||
); err != nil {
|
||||
return workflow.Run{}, fmt.Errorf("create workflow run: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListWorkflowRunsByTopic(ctx context.Context, topicID string) ([]workflow.Run, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, stage, mode, status, request_message_id, config_snapshot_json, command_json, reply_message_id, exit_code, started_at, completed_at, error_message
|
||||
FROM workflow_runs
|
||||
WHERE topic_id = ?
|
||||
ORDER BY started_at DESC, id DESC
|
||||
`, topicID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workflow runs by topic: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []workflow.Run
|
||||
for rows.Next() {
|
||||
item, err := scanWorkflowRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate workflow runs: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListWorkflowRunsByWorkspace(ctx context.Context, workspaceID string) ([]workflow.Run, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, stage, mode, status, request_message_id, config_snapshot_json, command_json, reply_message_id, exit_code, started_at, completed_at, error_message
|
||||
FROM workflow_runs
|
||||
WHERE workspace_id = ?
|
||||
ORDER BY started_at DESC, id DESC
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workflow runs by workspace: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []workflow.Run
|
||||
for rows.Next() {
|
||||
item, err := scanWorkflowRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate workflow runs by workspace: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetWorkflowRun(ctx context.Context, runID string) (workflow.Run, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, topic_id, role_name, stage, mode, status, request_message_id, config_snapshot_json, command_json, reply_message_id, exit_code, started_at, completed_at, error_message
|
||||
FROM workflow_runs
|
||||
WHERE id = ?
|
||||
`, runID)
|
||||
return scanWorkflowRun(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateWorkflowRun(ctx context.Context, value workflow.Run) (workflow.Run, error) {
|
||||
if value.ID == "" {
|
||||
return workflow.Run{}, fmt.Errorf("workflow run id is required")
|
||||
}
|
||||
before, err := s.GetWorkflowRun(ctx, value.ID)
|
||||
if err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
|
||||
value.StartedAt = before.StartedAt
|
||||
if value.ConfigSnapshotJSON == "" {
|
||||
value.ConfigSnapshotJSON = before.ConfigSnapshotJSON
|
||||
}
|
||||
if value.CommandJSON == "" {
|
||||
value.CommandJSON = before.CommandJSON
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE workflow_runs
|
||||
SET workspace_id = ?, topic_id = ?, role_name = ?, stage = ?, mode = ?, status = ?, request_message_id = ?,
|
||||
config_snapshot_json = ?, command_json = ?, reply_message_id = ?,
|
||||
exit_code = ?, completed_at = ?, error_message = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
value.WorkspaceID,
|
||||
value.TopicID,
|
||||
value.RoleName,
|
||||
string(value.Stage),
|
||||
value.Mode,
|
||||
string(value.Status),
|
||||
nullableString(value.RequestMessageID),
|
||||
value.ConfigSnapshotJSON,
|
||||
value.CommandJSON,
|
||||
nullableString(value.ReplyMessageID),
|
||||
value.ExitCode,
|
||||
nullableString(value.CompletedAt),
|
||||
value.ErrorMessage,
|
||||
value.ID,
|
||||
); err != nil {
|
||||
return workflow.Run{}, fmt.Errorf("update workflow run: %w", err)
|
||||
}
|
||||
return s.GetWorkflowRun(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (s *Store) AppendWorkflowRunLog(ctx context.Context, value workflow.RunLog) (workflow.RunLog, error) {
|
||||
if err := value.Validate(); err != nil {
|
||||
return workflow.RunLog{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return workflow.RunLog{}, fmt.Errorf("begin append workflow run log: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var nextSeq int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq), 0) + 1 FROM workflow_run_logs WHERE run_id = ?`, value.RunID).Scan(&nextSeq); err != nil {
|
||||
return workflow.RunLog{}, fmt.Errorf("load next workflow run log seq: %w", err)
|
||||
}
|
||||
value.Seq = nextSeq
|
||||
value.CreatedAt = coalesceString(value.CreatedAt, s.now())
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO workflow_run_logs(run_id, seq, stream, content, created_at)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
`, value.RunID, value.Seq, string(value.Stream), value.Content, value.CreatedAt); err != nil {
|
||||
return workflow.RunLog{}, fmt.Errorf("insert workflow run log: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return workflow.RunLog{}, fmt.Errorf("commit append workflow run log: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListWorkflowRunLogs(ctx context.Context, runID string, afterSeq int) ([]workflow.RunLog, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT run_id, seq, stream, content, created_at
|
||||
FROM workflow_run_logs
|
||||
WHERE run_id = ? AND seq > ?
|
||||
ORDER BY seq
|
||||
`, runID, afterSeq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workflow run logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []workflow.RunLog
|
||||
for rows.Next() {
|
||||
item, err := scanWorkflowRunLog(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate workflow run logs: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanWorkflowRun(s scanner) (workflow.Run, error) {
|
||||
var item workflow.Run
|
||||
var stage string
|
||||
var status string
|
||||
var requestMessageID sql.NullString
|
||||
var replyMessageID sql.NullString
|
||||
var completedAt sql.NullString
|
||||
if err := s.Scan(
|
||||
&item.ID,
|
||||
&item.WorkspaceID,
|
||||
&item.TopicID,
|
||||
&item.RoleName,
|
||||
&stage,
|
||||
&item.Mode,
|
||||
&status,
|
||||
&requestMessageID,
|
||||
&item.ConfigSnapshotJSON,
|
||||
&item.CommandJSON,
|
||||
&replyMessageID,
|
||||
&item.ExitCode,
|
||||
&item.StartedAt,
|
||||
&completedAt,
|
||||
&item.ErrorMessage,
|
||||
); err != nil {
|
||||
return workflow.Run{}, err
|
||||
}
|
||||
item.Stage = workflow.Stage(stage)
|
||||
item.Status = workflow.RunStatus(status)
|
||||
if requestMessageID.Valid {
|
||||
item.RequestMessageID = requestMessageID.String
|
||||
}
|
||||
if replyMessageID.Valid {
|
||||
item.ReplyMessageID = replyMessageID.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
item.CompletedAt = completedAt.String
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanWorkflowRunLog(s scanner) (workflow.RunLog, error) {
|
||||
var item workflow.RunLog
|
||||
var stream string
|
||||
if err := s.Scan(&item.RunID, &item.Seq, &stream, &item.Content, &item.CreatedAt); err != nil {
|
||||
return workflow.RunLog{}, err
|
||||
}
|
||||
item.Stream = workflow.LogStream(stream)
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"inbox/internal/base/timeutil"
|
||||
"inbox/internal/domain/role"
|
||||
"inbox/internal/domain/topic"
|
||||
"inbox/internal/domain/workflow"
|
||||
"inbox/internal/domain/workspace"
|
||||
)
|
||||
|
||||
func TestWorkflowRunLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clock := timeutil.FixedClock{Time: time.Date(2026, 3, 14, 10, 0, 0, 0, time.UTC)}
|
||||
store, err := OpenInMemory(clock)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenInMemory() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
project, err := store.CreateProject(ctx, workspace.Project{
|
||||
Slug: "demo",
|
||||
Name: "Demo",
|
||||
RootPath: "/tmp/demo",
|
||||
DefaultBranch: "main",
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject() error = %v", err)
|
||||
}
|
||||
ws, err := store.CreateWorkspace(ctx, workspace.Workspace{
|
||||
ProjectID: project.ID,
|
||||
Slug: "main",
|
||||
Name: "Main",
|
||||
RootPath: "/tmp/demo-main",
|
||||
BaseBranch: "main",
|
||||
WorktreeBranch: "worktree/main",
|
||||
RuntimeBackend: "local",
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace() error = %v", err)
|
||||
}
|
||||
if _, err := store.UpsertRole(ctx, role.Definition{
|
||||
Name: "backend",
|
||||
Title: "Backend",
|
||||
IsEnabled: true,
|
||||
IsBuiltin: true,
|
||||
}, "seed"); err != nil {
|
||||
t.Fatalf("UpsertRole() error = %v", err)
|
||||
}
|
||||
topicRecord, err := store.CreateTopic(ctx, topic.Record{
|
||||
WorkspaceID: ws.ID,
|
||||
Slug: "signup-flow",
|
||||
Title: "Signup Flow",
|
||||
Space: topic.SpaceWorkflow,
|
||||
Status: "execution",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic() error = %v", err)
|
||||
}
|
||||
|
||||
run, err := store.CreateWorkflowRun(ctx, workflow.Run{
|
||||
WorkspaceID: ws.ID,
|
||||
TopicID: topicRecord.ID,
|
||||
RoleName: "backend",
|
||||
Stage: workflow.StageExecution,
|
||||
Status: workflow.RunStatusRunning,
|
||||
ConfigSnapshotJSON: `{"role":"backend"}`,
|
||||
CommandJSON: `["go","test","./..."]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkflowRun() error = %v", err)
|
||||
}
|
||||
|
||||
run.Status = workflow.RunStatusSucceeded
|
||||
run.CompletedAt = timeutil.FormatRFC3339(clock.Now())
|
||||
run.ExitCode = 0
|
||||
run, err = store.UpdateWorkflowRun(ctx, run)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWorkflowRun() error = %v", err)
|
||||
}
|
||||
if run.Status != workflow.RunStatusSucceeded {
|
||||
t.Fatalf("unexpected run after update: %#v", run)
|
||||
}
|
||||
|
||||
firstLog, err := store.AppendWorkflowRunLog(ctx, workflow.RunLog{
|
||||
RunID: run.ID,
|
||||
Stream: workflow.LogStreamStdout,
|
||||
Content: "line 1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendWorkflowRunLog(first) error = %v", err)
|
||||
}
|
||||
secondLog, err := store.AppendWorkflowRunLog(ctx, workflow.RunLog{
|
||||
RunID: run.ID,
|
||||
Stream: workflow.LogStreamStderr,
|
||||
Content: "line 2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendWorkflowRunLog(second) error = %v", err)
|
||||
}
|
||||
if firstLog.Seq != 1 || secondLog.Seq != 2 {
|
||||
t.Fatalf("unexpected log seqs: first=%d second=%d", firstLog.Seq, secondLog.Seq)
|
||||
}
|
||||
|
||||
logs, err := store.ListWorkflowRunLogs(ctx, run.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkflowRunLogs() error = %v", err)
|
||||
}
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("expected 2 logs, got %d", len(logs))
|
||||
}
|
||||
|
||||
filtered, err := store.ListWorkflowRunLogs(ctx, run.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkflowRunLogs(after) error = %v", err)
|
||||
}
|
||||
if len(filtered) != 1 || filtered[0].Seq != 2 {
|
||||
t.Fatalf("unexpected filtered logs: %#v", filtered)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user