Changeset 0.29.0 (#195)
This commit is contained in:
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
@@ -66,34 +65,10 @@ func (t *conversationSearchTool) Execute(ctx context.Context, execCtx ExecutionC
|
||||
args.MaxResults = 5
|
||||
}
|
||||
|
||||
// Build query — full-text search scoped to this channel
|
||||
// Uses on-the-fly to_tsvector (no index needed — channel-scoped is bounded)
|
||||
roleClause := ""
|
||||
queryArgs := []interface{}{execCtx.ChannelID, args.Query, args.MaxResults}
|
||||
if args.RoleFilter == "user" || args.RoleFilter == "assistant" {
|
||||
roleClause = "AND m.role = $4"
|
||||
queryArgs = append(queryArgs, args.RoleFilter)
|
||||
}
|
||||
|
||||
rows, err := database.DB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT m.id, m.role,
|
||||
ts_headline('english', m.content, plainto_tsquery('english', $2),
|
||||
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline,
|
||||
ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank,
|
||||
m.created_at
|
||||
FROM messages m
|
||||
WHERE m.channel_id = $1
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.role IN ('user', 'assistant')
|
||||
AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2)
|
||||
%s
|
||||
ORDER BY rank DESC, m.created_at DESC
|
||||
LIMIT $3
|
||||
`, roleClause), queryArgs...)
|
||||
storeResults, err := t.stores.Messages.SearchInChannel(ctx, execCtx.ChannelID, args.Query, args.RoleFilter, args.MaxResults)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type searchResult struct {
|
||||
MessageID string `json:"message_id"`
|
||||
@@ -103,17 +78,15 @@ func (t *conversationSearchTool) Execute(ctx context.Context, execCtx ExecutionC
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
results := make([]searchResult, 0)
|
||||
for rows.Next() {
|
||||
var r searchResult
|
||||
var rank float64
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &rank, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Rank = rank
|
||||
r.Timestamp = createdAt.Format(time.RFC3339)
|
||||
results = append(results, r)
|
||||
results := make([]searchResult, 0, len(storeResults))
|
||||
for _, sr := range storeResults {
|
||||
results = append(results, searchResult{
|
||||
MessageID: sr.MessageID,
|
||||
Role: sr.Role,
|
||||
Excerpt: sr.Excerpt,
|
||||
Rank: sr.Rank,
|
||||
Timestamp: sr.Timestamp.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("🔍 conversation_search: %q → %d results in channel %s",
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -132,13 +131,7 @@ func (t *memorySaveTool) embedMemory(ctx context.Context, m *models.Memory, user
|
||||
}
|
||||
|
||||
vecStr := vectorToString(result.Vectors[0])
|
||||
if database.IsSQLite() {
|
||||
database.DB.Exec(`UPDATE memories SET embedding = ? WHERE id = ?`,
|
||||
vecStr, m.ID)
|
||||
} else {
|
||||
database.DB.Exec(`UPDATE memories SET embedding = $1::vector WHERE id = $2`,
|
||||
vecStr, m.ID)
|
||||
}
|
||||
_ = t.stores.Memories.SetEmbedding(ctx, m.ID, vecStr)
|
||||
|
||||
log.Printf("🧠 memory %s embedded (%d dims)", m.ID[:8], len(result.Vectors[0]))
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
@@ -26,7 +24,7 @@ func RegisterNoteTools(stores store.Stores, embedder *knowledge.Embedder) {
|
||||
Register(¬eCreateTool{stores: stores, embedder: embedder})
|
||||
Register(¬eSearchTool{stores: stores, embedder: embedder})
|
||||
Register(¬eUpdateTool{stores: stores, embedder: embedder})
|
||||
Register(¬eListTool{})
|
||||
Register(¬eListTool{stores: stores})
|
||||
}
|
||||
|
||||
// ── Shared helpers ─────────────────────────────
|
||||
@@ -61,12 +59,8 @@ func embedNote(ctx context.Context, embedder *knowledge.Embedder, stores store.S
|
||||
return
|
||||
}
|
||||
|
||||
// Store the vector — format as pgvector literal
|
||||
vecStr := vectorToString(result.Vectors[0])
|
||||
_, err = database.DB.Exec(
|
||||
`UPDATE notes SET embedding = $1::vector WHERE id = $2`,
|
||||
vecStr, noteID,
|
||||
)
|
||||
// Store the vector via store method
|
||||
err = stores.Notes.SetEmbedding(ctx, noteID, vectorToString(result.Vectors[0]))
|
||||
if err != nil {
|
||||
log.Printf("⚠ note embed store failed for %s: %v", noteID, err)
|
||||
return
|
||||
@@ -138,27 +132,28 @@ func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
sourceChannelID = &execCtx.ChannelID
|
||||
}
|
||||
|
||||
var id, createdAt string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at::text
|
||||
`, execCtx.UserID, args.Title, args.Content, folder, pq.Array(tags), sourceChannelID,
|
||||
).Scan(&id, &createdAt)
|
||||
note := &models.Note{
|
||||
UserID: execCtx.UserID,
|
||||
Title: args.Title,
|
||||
Content: args.Content,
|
||||
FolderPath: folder,
|
||||
Tags: tags,
|
||||
SourceChannelID: sourceChannelID,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err := t.stores.Notes.Create(ctx, note); err != nil {
|
||||
return "", fmt.Errorf("failed to create note: %w", err)
|
||||
}
|
||||
|
||||
// Embed async — don't block the tool response
|
||||
go embedNote(context.Background(), t.embedder, t.stores, id, execCtx.UserID, args.Title, args.Content)
|
||||
go embedNote(context.Background(), t.embedder, t.stores, note.ID, execCtx.UserID, args.Title, args.Content)
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"id": id,
|
||||
"id": note.ID,
|
||||
"title": args.Title,
|
||||
"folder": folder,
|
||||
"tags": tags,
|
||||
"created_at": createdAt,
|
||||
"created_at": note.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"message": fmt.Sprintf("Note '%s' created successfully.", args.Title),
|
||||
})
|
||||
return string(result), nil
|
||||
@@ -220,35 +215,22 @@ func (t *noteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
}
|
||||
|
||||
func (t *noteSearchTool) keywordSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) {
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 500),
|
||||
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
|
||||
ts_headline('english', content, plainto_tsquery('english', $2),
|
||||
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline
|
||||
FROM notes
|
||||
WHERE user_id = $1
|
||||
AND search_vector @@ plainto_tsquery('english', $2)
|
||||
ORDER BY rank DESC
|
||||
LIMIT $3
|
||||
`, execCtx.UserID, query, limit)
|
||||
|
||||
storeResults, err := t.stores.Notes.SearchKeyword(ctx, execCtx.UserID, query, limit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]noteSearchResult, 0)
|
||||
for rows.Next() {
|
||||
var r noteSearchResult
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
results = append(results, r)
|
||||
results := make([]noteSearchResult, 0, len(storeResults))
|
||||
for _, sr := range storeResults {
|
||||
results = append(results, noteSearchResult{
|
||||
ID: sr.ID,
|
||||
Title: sr.Title,
|
||||
Folder: sr.FolderPath,
|
||||
Tags: sr.Tags,
|
||||
Excerpt: sr.Excerpt,
|
||||
Headline: sr.Headline,
|
||||
Rank: sr.Rank,
|
||||
})
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -292,36 +274,23 @@ func (t *noteSearchTool) semanticSearch(ctx context.Context, execCtx ExecutionCo
|
||||
|
||||
queryVec := vectorToString(embedResult.Vectors[0])
|
||||
|
||||
// Vector similarity search — only notes that have embeddings
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 500),
|
||||
1 - (embedding <=> $2::vector) AS similarity
|
||||
FROM notes
|
||||
WHERE user_id = $1
|
||||
AND embedding IS NOT NULL
|
||||
AND 1 - (embedding <=> $2::vector) > 0.3
|
||||
ORDER BY embedding <=> $2::vector
|
||||
LIMIT $3
|
||||
`, execCtx.UserID, queryVec, limit)
|
||||
|
||||
// Vector similarity search via store
|
||||
storeResults, err := t.stores.Notes.SearchSemantic(ctx, execCtx.UserID, queryVec, limit)
|
||||
if err != nil {
|
||||
log.Printf("⚠ note semantic search query failed: %v — falling back to keyword", err)
|
||||
return t.keywordSearch(ctx, execCtx, query, limit)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := make([]noteSearchResult, 0)
|
||||
for rows.Next() {
|
||||
var r noteSearchResult
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Similarity); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
results = append(results, r)
|
||||
results := make([]noteSearchResult, 0, len(storeResults))
|
||||
for _, sr := range storeResults {
|
||||
results = append(results, noteSearchResult{
|
||||
ID: sr.ID,
|
||||
Title: sr.Title,
|
||||
Folder: sr.FolderPath,
|
||||
Tags: sr.Tags,
|
||||
Excerpt: sr.Excerpt,
|
||||
Similarity: sr.Rank,
|
||||
})
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -386,60 +355,57 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
return "", fmt.Errorf("note_id is required")
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
setClauses := []string{}
|
||||
queryArgs := []interface{}{}
|
||||
argIdx := 1
|
||||
// Verify ownership + get existing content for append/prepend
|
||||
existing, err := t.stores.Notes.GetByID(ctx, args.NoteID)
|
||||
if err != nil || existing.UserID != execCtx.UserID {
|
||||
return "", fmt.Errorf("note not found or update failed")
|
||||
}
|
||||
|
||||
// Build fields map for store.Update
|
||||
fields := map[string]interface{}{}
|
||||
|
||||
if args.Title != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("title = $%d", argIdx))
|
||||
queryArgs = append(queryArgs, *args.Title)
|
||||
argIdx++
|
||||
fields["title"] = *args.Title
|
||||
}
|
||||
|
||||
if args.Content != nil {
|
||||
mode := strings.ToLower(args.Mode)
|
||||
switch mode {
|
||||
case "append":
|
||||
setClauses = append(setClauses, fmt.Sprintf("content = content || $%d", argIdx))
|
||||
fields["content"] = existing.Content + *args.Content
|
||||
case "prepend":
|
||||
setClauses = append(setClauses, fmt.Sprintf("content = $%d || content", argIdx))
|
||||
fields["content"] = *args.Content + existing.Content
|
||||
default:
|
||||
setClauses = append(setClauses, fmt.Sprintf("content = $%d", argIdx))
|
||||
fields["content"] = *args.Content
|
||||
}
|
||||
queryArgs = append(queryArgs, *args.Content)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if args.Tags != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("tags = $%d", argIdx))
|
||||
queryArgs = append(queryArgs, pq.Array(args.Tags))
|
||||
argIdx++
|
||||
fields["tags"] = args.Tags
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
if len(fields) == 0 {
|
||||
return "", fmt.Errorf("no fields to update — provide at least title, content, or tags")
|
||||
}
|
||||
|
||||
queryArgs = append(queryArgs, args.NoteID, execCtx.UserID)
|
||||
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
|
||||
fmt.Sprintf(" WHERE id = $%d AND user_id = $%d", argIdx, argIdx+1) +
|
||||
" RETURNING id, title, content, updated_at::text"
|
||||
if err := t.stores.Notes.Update(ctx, args.NoteID, fields); err != nil {
|
||||
return "", fmt.Errorf("note not found or update failed")
|
||||
}
|
||||
|
||||
var id, title, content, updatedAt string
|
||||
err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &content, &updatedAt)
|
||||
// Re-fetch to get updated values
|
||||
updated, err := t.stores.Notes.GetByID(ctx, args.NoteID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("note not found or update failed")
|
||||
}
|
||||
|
||||
// Re-embed async with updated content
|
||||
go embedNote(context.Background(), t.embedder, t.stores, id, execCtx.UserID, title, content)
|
||||
go embedNote(context.Background(), t.embedder, t.stores, updated.ID, execCtx.UserID, updated.Title, updated.Content)
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"id": id,
|
||||
"title": title,
|
||||
"updated_at": updatedAt,
|
||||
"message": fmt.Sprintf("Note '%s' updated successfully.", title),
|
||||
"id": updated.ID,
|
||||
"title": updated.Title,
|
||||
"updated_at": updated.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"message": fmt.Sprintf("Note '%s' updated successfully.", updated.Title),
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
@@ -448,7 +414,10 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// note_list
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteListTool struct{ visitorDeniedBase }
|
||||
type noteListTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func (t *noteListTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -478,31 +447,20 @@ func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, ar
|
||||
args.Limit = 20
|
||||
}
|
||||
|
||||
query := `SELECT id, title, folder_path, tags, LEFT(content, 100),
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE user_id = $1`
|
||||
queryArgs := []interface{}{execCtx.UserID}
|
||||
argIdx := 2
|
||||
|
||||
opts := store.NoteListOptions{
|
||||
ListOptions: store.ListOptions{Limit: args.Limit},
|
||||
}
|
||||
if args.Folder != "" {
|
||||
query += fmt.Sprintf(" AND folder_path = $%d", argIdx)
|
||||
queryArgs = append(queryArgs, normalizePath(args.Folder))
|
||||
argIdx++
|
||||
opts.FolderPath = normalizePath(args.Folder)
|
||||
}
|
||||
if args.Tag != "" {
|
||||
query += fmt.Sprintf(" AND $%d = ANY(tags)", argIdx)
|
||||
queryArgs = append(queryArgs, args.Tag)
|
||||
argIdx++
|
||||
opts.Tag = args.Tag
|
||||
}
|
||||
|
||||
query += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", argIdx)
|
||||
queryArgs = append(queryArgs, args.Limit)
|
||||
|
||||
rows, err := database.DB.Query(query, queryArgs...)
|
||||
notes, _, err := t.stores.Notes.ListForUser(ctx, execCtx.UserID, opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list notes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
@@ -514,19 +472,25 @@ func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, ar
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
items := make([]item, 0)
|
||||
for rows.Next() {
|
||||
var n item
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.Folder, &dbTags, &n.Preview,
|
||||
&n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
continue
|
||||
items := make([]item, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
tags := n.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
n.Tags = []string(dbTags)
|
||||
if n.Tags == nil {
|
||||
n.Tags = []string{}
|
||||
preview := n.Content
|
||||
if len(preview) > 100 {
|
||||
preview = preview[:100]
|
||||
}
|
||||
items = append(items, n)
|
||||
items = append(items, item{
|
||||
ID: n.ID,
|
||||
Title: n.Title,
|
||||
Folder: n.FolderPath,
|
||||
Tags: tags,
|
||||
Preview: preview,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
|
||||
@@ -112,10 +111,7 @@ func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
|
||||
// Depth guard: verify we're not inside a service channel (task output)
|
||||
if execCtx.ChannelID != "" {
|
||||
var channelType string
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(
|
||||
`SELECT type FROM channels WHERE id = $1`,
|
||||
), execCtx.ChannelID).Scan(&channelType)
|
||||
channelType, _, _ := t.stores.Channels.GetTypeAndTeamID(ctx, execCtx.ChannelID)
|
||||
if channelType == "service" {
|
||||
return "", fmt.Errorf("tasks cannot create tasks (depth limit)")
|
||||
}
|
||||
@@ -124,10 +120,7 @@ func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// Resolve persona ID from handle
|
||||
var personaID *string
|
||||
if args.Persona != "" {
|
||||
var pID string
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(
|
||||
`SELECT id FROM personas WHERE LOWER(handle) = LOWER($1) AND is_active = true`,
|
||||
), args.Persona).Scan(&pID)
|
||||
pID, err := t.stores.Personas.FindActiveByHandle(ctx, args.Persona)
|
||||
if err == nil && pID != "" {
|
||||
personaID = &pID
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/webhook"
|
||||
@@ -79,18 +78,13 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
}
|
||||
|
||||
// Read current workflow state
|
||||
var workflowID string
|
||||
var currentStage int
|
||||
var status string
|
||||
var wfID *string
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active')
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`), channelID).Scan(&wfID, ¤tStage, &status)
|
||||
if err != nil || wfID == nil {
|
||||
ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil {
|
||||
return "", fmt.Errorf("not a workflow channel")
|
||||
}
|
||||
workflowID = *wfID
|
||||
workflowID := *ws.WorkflowID
|
||||
currentStage := ws.CurrentStage
|
||||
status := ws.Status
|
||||
|
||||
if status != "active" {
|
||||
return "", fmt.Errorf("workflow is %s, cannot advance", status)
|
||||
@@ -103,17 +97,12 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
}
|
||||
|
||||
// Merge collected data into stage_data
|
||||
mergedData := MergeWorkflowStageData(ctx, channelID, args.Data)
|
||||
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.Data)
|
||||
nextStage := currentStage + 1
|
||||
|
||||
if nextStage >= len(stages) {
|
||||
// Workflow complete
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET current_stage = $1, workflow_status = 'completed',
|
||||
stage_data = $2, last_activity_at = $3, ai_mode = 'off'
|
||||
WHERE id = $4
|
||||
`), nextStage, mergedData, time.Now().UTC(), channelID)
|
||||
err = t.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to complete workflow: %w", err)
|
||||
}
|
||||
@@ -133,11 +122,7 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
}
|
||||
|
||||
// Advance to next stage
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3
|
||||
WHERE id = $4
|
||||
`), nextStage, mergedData, time.Now().UTC(), channelID)
|
||||
err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to advance: %w", err)
|
||||
}
|
||||
@@ -148,7 +133,7 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
// Create assignment if next stage has an assignment team
|
||||
nextStageDef := stages[nextStage]
|
||||
if nextStageDef.AssignmentTeamID != nil {
|
||||
CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
|
||||
CreateWorkflowAssignment(ctx, t.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -163,11 +148,9 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
// ── Shared helpers (used by both tool and handler) ─
|
||||
|
||||
// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string.
|
||||
func MergeWorkflowStageData(ctx context.Context, channelID string, newData json.RawMessage) string {
|
||||
var existing []byte
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&existing)
|
||||
// v0.29.0: channelStore parameter replaces raw database.DB access.
|
||||
func MergeWorkflowStageData(ctx context.Context, channelStore store.ChannelStore, channelID string, newData json.RawMessage) string {
|
||||
existing, _ := channelStore.GetStageData(ctx, channelID)
|
||||
|
||||
if len(newData) == 0 || string(newData) == "null" {
|
||||
if len(existing) == 0 {
|
||||
@@ -204,10 +187,11 @@ func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID
|
||||
content := string(data)
|
||||
|
||||
// Find workflow channel owner for the note's user_id
|
||||
var userID string
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT user_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&userID)
|
||||
ch, err := stores.Channels.GetByID(ctx, channelID)
|
||||
if err != nil || ch == nil {
|
||||
return
|
||||
}
|
||||
userID := ch.UserID
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
@@ -226,28 +210,18 @@ func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID
|
||||
|
||||
// CreateWorkflowAssignment creates an unassigned workflow assignment entry.
|
||||
// Returns the assignment ID (for round-robin auto-assignment).
|
||||
func CreateWorkflowAssignment(ctx context.Context, channelID string, stage int, teamID string) string {
|
||||
id := store.NewID()
|
||||
if database.IsSQLite() {
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, id, channelID, stage, teamID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
// v0.29.0: accepts stores parameter, delegates to WorkflowStore.
|
||||
func CreateWorkflowAssignment(ctx context.Context, stores store.Stores, channelID string, stage int, teamID string) string {
|
||||
a := &store.WorkflowAssignment{
|
||||
ChannelID: channelID,
|
||||
Stage: stage,
|
||||
TeamID: teamID,
|
||||
}
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, id, channelID, stage, teamID)
|
||||
if err != nil {
|
||||
if err := stores.Workflows.CreateAssignment(ctx, a); err != nil {
|
||||
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
return a.ID
|
||||
}
|
||||
|
||||
// ── Workflow Completion Triggers (v0.27.3) ──
|
||||
@@ -307,10 +281,11 @@ func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflo
|
||||
targetData = sourceData
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(
|
||||
`SELECT user_id FROM channels WHERE id = $1`,
|
||||
), channelID).Scan(&ownerID)
|
||||
ch2, err2 := stores.Channels.GetByID(ctx, channelID)
|
||||
if err2 != nil || ch2 == nil {
|
||||
return
|
||||
}
|
||||
ownerID := ch2.UserID
|
||||
if ownerID == "" {
|
||||
return
|
||||
}
|
||||
@@ -338,17 +313,12 @@ func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflo
|
||||
}
|
||||
|
||||
initialData, _ := json.Marshal(targetData)
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||
stage_data = $3, workflow_status = 'active',
|
||||
last_activity_at = $4, ai_mode = 'auto'
|
||||
WHERE id = $5
|
||||
`), target.ID, ver.VersionNumber, string(initialData), time.Now().UTC(), ch.ID)
|
||||
err = stores.Channels.SetWorkflowInstance(ctx, ch.ID, target.ID, ver.VersionNumber, initialData, "active")
|
||||
if err != nil {
|
||||
log.Printf("[workflow] on_complete: failed to set workflow columns: %v", err)
|
||||
return
|
||||
}
|
||||
_ = stores.Channels.Update(ctx, ch.ID, map[string]interface{}{"ai_mode": "auto"})
|
||||
|
||||
// Bind first stage persona
|
||||
if len(stages) > 0 && stages[0].PersonaID != nil {
|
||||
|
||||
Reference in New Issue
Block a user