chore(repo): reinitialize repository

This commit is contained in:
2026-03-18 11:29:54 +08:00
commit 24871e213a
288 changed files with 44369 additions and 0 deletions
@@ -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, &notNull, &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, &notNull, &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, &notNull, &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, &notNull, &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, &notNull, &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, &notNull, &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, &notNull, &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)
}
}
+226
View File
@@ -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)
+209
View File
@@ -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
}
+276
View File
@@ -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';
@@ -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;
@@ -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;
@@ -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.
@@ -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;
@@ -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
@@ -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, &notNull, &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
}
+404
View File
@@ -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
}
+192
View File
@@ -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
}
+158
View File
@@ -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
}
+357
View File
@@ -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, &notNull, &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
}
+413
View File
@@ -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
}
+178
View File
@@ -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)
}
}