Changeset 0.27.1 (#167)

This commit is contained in:
2026-03-10 17:43:29 +00:00
parent 7e4f1581f2
commit 41be9d6081
16 changed files with 780 additions and 57 deletions

View File

@@ -66,6 +66,12 @@ var routeTable = map[string]Direction{
// Workspace (v0.21.5)
"workspace.file.": DirToClient, // file changed events for live editor updates
// Workflow (v0.27.0)
"workflow.assigned": DirToClient, // new assignment → team members
"workflow.claimed": DirToClient, // assignment claimed → team + claimer
"workflow.advanced": DirToClient, // stage advanced → channel participants
"workflow.completed": DirToClient, // workflow finished → channel participants
// Plugin hooks — never cross the wire
"plugin.hook.": DirLocal,
"internal.": DirLocal,

View File

@@ -85,7 +85,7 @@ func TestRouteRegistration(t *testing.T) {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances
wfInstH := NewWorkflowInstanceHandler(stores)
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -10,7 +10,9 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -20,11 +22,13 @@ import (
// advancing/rejecting stages, and querying status.
type WorkflowInstanceHandler struct {
stores store.Stores
stores store.Stores
hub *events.Hub
notifSvc *notifications.Service
}
func NewWorkflowInstanceHandler(stores store.Stores) *WorkflowInstanceHandler {
return &WorkflowInstanceHandler{stores: stores}
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler {
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc}
}
// ── Start ───────────────────────────────────
@@ -198,6 +202,15 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
return
}
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
// v0.27.0: Emit workflow.completed WS event
h.emitWorkflowEvent("workflow.completed", channelID, map[string]any{
"channel_id": channelID, "workflow_id": workflowID, "stage": nextStage,
})
// v0.27.0: on_complete chaining — trigger target workflow if configured
h.triggerOnComplete(ctx, workflowID, channelID, mergedData)
c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage})
return
}
@@ -230,13 +243,23 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
}
}
// Notify assignment team (stub — fully wired in v0.26.4)
// v0.27.0: Assignment + round-robin + WS notifications
if nextStageDef.AssignmentTeamID != nil {
tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
log.Printf("Workflow stage '%s' assigned to team %s (channel %s)",
nextStageDef.Name, *nextStageDef.AssignmentTeamID, channelID)
assignmentID := tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
// Round-robin auto-assignment if configured
assignedTo := h.tryRoundRobin(ctx, nextStageDef, assignmentID)
// Notify team members about new assignment
h.notifyAssignment(ctx, *nextStageDef.AssignmentTeamID, channelID, nextStageDef.Name, assignedTo)
}
// v0.27.0: Emit workflow.advanced WS event
h.emitWorkflowEvent("workflow.advanced", channelID, map[string]any{
"channel_id": channelID, "workflow_id": workflowID,
"stage": nextStage, "stage_name": nextStageDef.Name,
})
c.JSON(http.StatusOK, gin.H{
"status": "active",
"current_stage": nextStage,
@@ -315,5 +338,236 @@ func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channel
return
}
// emitWorkflowEvent pushes a workflow event to all connected clients in the channel's room.
func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, data map[string]any) {
if h.hub == nil {
return
}
payload, _ := json.Marshal(data)
h.hub.GetBus().Publish(events.Event{
Label: label,
Room: channelID,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
// triggerOnComplete checks if the workflow has an on_complete chain config
// and starts the target workflow if so.
func (h *WorkflowInstanceHandler) triggerOnComplete(ctx context.Context, workflowID, channelID, mergedData string) {
if h.stores.Workflows == nil {
return
}
wf, err := h.stores.Workflows.GetByID(ctx, workflowID)
if err != nil || wf == nil || len(wf.OnComplete) == 0 || string(wf.OnComplete) == "null" {
return
}
var chain struct {
Action string `json:"action"`
TargetSlug string `json:"target_slug"`
DataMap map[string]string `json:"data_map"` // source_key → target_key
}
if err := json.Unmarshal(wf.OnComplete, &chain); err != nil || chain.Action != "start_workflow" || chain.TargetSlug == "" {
return
}
// Look up target workflow
target, err := h.stores.Workflows.GetBySlug(ctx, wf.TeamID, chain.TargetSlug)
if err != nil || target == nil || !target.IsActive {
log.Printf("[workflow] on_complete: target workflow '%s' not found or inactive", chain.TargetSlug)
return
}
// Map stage_data from completed workflow to initial stage_data for target
var sourceData map[string]any
if err := json.Unmarshal([]byte(mergedData), &sourceData); err != nil {
sourceData = map[string]any{}
}
targetData := map[string]any{}
if len(chain.DataMap) > 0 {
for srcKey, tgtKey := range chain.DataMap {
if v, ok := sourceData[srcKey]; ok {
targetData[tgtKey] = v
}
}
} else {
// No mapping = pass all data through
targetData = sourceData
}
// Get the channel owner to start the chained workflow as
var ownerID string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT user_id FROM channels WHERE id = $1
`), channelID).Scan(&ownerID)
if ownerID == "" {
return
}
// Create a new workflow channel for the target
ver, err := h.stores.Workflows.GetLatestVersion(ctx, target.ID)
if err != nil {
log.Printf("[workflow] on_complete: target '%s' has no published version", chain.TargetSlug)
return
}
stages, err := h.stores.Workflows.ListStages(ctx, target.ID)
if err != nil || len(stages) == 0 {
return
}
ch := &models.Channel{
UserID: ownerID,
Title: target.Name + " (chained)",
Description: target.Description,
Type: "workflow",
TeamID: target.TeamID,
}
if err := h.stores.Channels.Create(ctx, ch); err != nil {
log.Printf("[workflow] on_complete: failed to create chained channel: %v", err)
return
}
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)
if err != nil {
log.Printf("[workflow] on_complete: failed to set workflow columns: %v", err)
return
}
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
ChannelID: ch.ID, ParticipantType: "user", ParticipantID: ownerID, Role: "owner",
})
if stages[0].PersonaID != nil {
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
ChannelID: ch.ID, ParticipantType: "persona", ParticipantID: *stages[0].PersonaID, Role: "member",
})
}
log.Printf("[workflow] on_complete: chained '%s' → '%s' (channel %s → %s)",
wf.Slug, target.Slug, channelID, ch.ID)
}
// tryRoundRobin checks if the stage has auto_assign:"round_robin" in
// transition_rules and assigns the newly created assignment to the
// least-recently-assigned team member. Returns the assigned user ID or "".
func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage models.WorkflowStage, assignmentID string) string {
if assignmentID == "" || stage.AssignmentTeamID == nil {
return ""
}
// Check transition_rules for auto_assign
var rules struct {
AutoAssign string `json:"auto_assign"`
}
if len(stage.TransitionRules) > 0 {
_ = json.Unmarshal(stage.TransitionRules, &rules)
}
if rules.AutoAssign != "round_robin" {
return ""
}
// Get team members
members, err := h.stores.Teams.ListMembers(ctx, *stage.AssignmentTeamID)
if err != nil || len(members) == 0 {
return ""
}
// Find the least-recently-assigned member.
// Query: for each member, find their most recent claimed_at in workflow_assignments.
// Pick the member with the oldest (or null) claimed_at.
var bestUserID string
bestUserID = members[0].UserID // fallback to first member
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT m.user_id, MAX(wa.claimed_at) as last_claim
FROM team_members m
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
WHERE m.team_id = $2
GROUP BY m.user_id
ORDER BY last_claim ASC NULLS FIRST
LIMIT 1
`), *stage.AssignmentTeamID, *stage.AssignmentTeamID)
if err == nil {
defer rows.Close()
if rows.Next() {
var uid string
var lastClaim *time.Time
if err := rows.Scan(&uid, &lastClaim); err == nil {
bestUserID = uid
}
}
}
// Claim the assignment for this user
now := time.Now().UTC()
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE workflow_assignments
SET assigned_to = $1, status = 'claimed', claimed_at = $2
WHERE id = $3 AND status = 'unassigned'
`), bestUserID, now, assignmentID)
if err != nil {
log.Printf("[workflow] round-robin: failed to auto-assign %s to %s: %v", assignmentID, bestUserID, err)
return ""
}
log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, bestUserID)
return bestUserID
}
// notifyAssignment sends notifications to team members about a new workflow assignment.
func (h *WorkflowInstanceHandler) notifyAssignment(ctx context.Context, teamID, channelID, stageName, assignedTo string) {
if h.notifSvc == nil || h.stores.Teams == nil {
return
}
members, err := h.stores.Teams.ListMembers(ctx, teamID)
if err != nil {
return
}
for _, m := range members {
title := "New workflow assignment"
body := "Stage '" + stageName + "' needs review"
if assignedTo != "" && m.UserID == assignedTo {
title = "Workflow assigned to you"
body = "Stage '" + stageName + "' has been assigned to you (round-robin)"
}
n := &models.Notification{
UserID: m.UserID,
Type: "workflow.assigned",
Title: title,
Body: body,
ResourceType: models.ResourceTypeChannel,
ResourceID: channelID,
}
if err := h.notifSvc.Notify(ctx, n); err != nil {
log.Printf("[workflow] notify assignment: %v", err)
}
}
// Also emit targeted WS event for immediate UI update
if h.hub != nil {
payload, _ := json.Marshal(map[string]any{
"channel_id": channelID, "team_id": teamID,
"stage_name": stageName, "assigned_to": assignedTo,
})
for _, m := range members {
h.hub.SendToUser(m.UserID, events.Event{
Label: "workflow.assigned",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
}
}
// createStageNote and mergeStageData are now in tools/workflow.go
// (shared between handler and workflow_advance tool).

View File

@@ -311,7 +311,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances
wfInstH := NewWorkflowInstanceHandler(stores)
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -160,12 +160,15 @@ func main() {
}
// Background workflow staleness sweep (v0.26.2): mark idle instances as stale
// + v0.27.0: retention enforcement — archive/delete completed instances per workflow policy
if cfg.WorkflowStaleHours > 0 {
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// Staleness: mark idle active instances as stale
cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour)
res, err := database.DB.ExecContext(ctx, database.Q(`
UPDATE channels
@@ -179,6 +182,33 @@ func main() {
} else if n, _ := res.RowsAffected(); n > 0 {
log.Printf("🧹 workflows: marked %d instances as stale", n)
}
// v0.27.0: Retention enforcement — delete completed workflow channels
// where the parent workflow has retention.mode="delete" and
// retention.delete_after_days has elapsed since completion.
res2, err := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM channels
WHERE type = 'workflow'
AND workflow_status IN ('completed', 'archived')
AND workflow_id IS NOT NULL
AND last_activity_at < $1
AND workflow_id IN (
SELECT id FROM workflows
WHERE retention IS NOT NULL
AND retention->>'mode' = 'delete'
AND (retention->>'delete_after_days')::int > 0
AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval
)
`), cutoff)
if err != nil {
// SQLite doesn't support JSON operators — skip retention on SQLite
if !database.IsSQLite() {
log.Printf("⚠ workflow retention enforcement failed: %v", err)
}
} else if n, _ := res2.RowsAffected(); n > 0 {
log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n)
}
cancel()
<-ticker.C
}
@@ -554,7 +584,7 @@ func main() {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances (v0.26.2 — runtime lifecycle)
wfInstH := handlers.NewWorkflowInstanceHandler(stores)
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc)
protected.POST("/workflows/:id/start", wfInstH.Start)
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)

View File

@@ -243,6 +243,9 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* v0.23.2: Channel context banner — shown for DMs and named channels */}}
<div id="channelContextBanner" class="channel-context-banner" style="display:none;"></div>
{{/* v0.27.0: Workflow stage indicator — shown for workflow channels */}}
<div id="workflowStageBar" class="workflow-stage-bar" style="display:none;"></div>
{{/* Messages */}}
<div id="chatMessages" class="msg-container">
{{/* Populated by UI.renderMessages() */}}

View File

@@ -30,6 +30,7 @@
<a href="{{$base}}/settings/personas" class="settings-nav-link{{if eq $section "personas"}} active{{end}}">Personas</a>
<a href="{{$base}}/settings/profile" class="settings-nav-link{{if eq $section "profile"}} active{{end}}">Profile</a>
<a href="{{$base}}/settings/teams" class="settings-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a>
<a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a>
{{/* BYOK-gated nav items */}}
<div id="settingsByokNav" style="display:none;">
@@ -55,7 +56,7 @@
{{/* Content */}}
<div class="settings-content" id="settingsContentMount">
<div id="settingsSection" data-section="{{.Section}}">
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2>
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "workflows"}}Workflows{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2>
{{if eq .Section "general"}}
<div class="settings-section">
@@ -151,6 +152,8 @@
<div id="myUsageTotals"></div>
{{else if eq .Section "teams"}}
<div id="settingsDynamic"><div class="settings-placeholder">Loading teams…</div></div>
{{else if eq .Section "workflows"}}
<div id="settingsDynamic"><div class="settings-placeholder">Loading workflows…</div></div>
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
{{end}}
</div>
@@ -247,6 +250,7 @@
memory: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.(),
notifications: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.(),
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
};
// Populate App.policies and App.models from API.

View File

@@ -221,23 +221,27 @@ func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID
}
// CreateWorkflowAssignment creates an unassigned workflow assignment entry.
func CreateWorkflowAssignment(ctx context.Context, channelID string, stage int, teamID string) {
// 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() {
id := store.NewID()
_, 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
return id
}
_, err := database.DB.ExecContext(ctx, `
INSERT INTO workflow_assignments (channel_id, stage, team_id)
VALUES ($1, $2, $3)
`, channelID, stage, teamID)
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
VALUES ($1, $2, $3, $4)
`, id, channelID, stage, teamID)
if err != nil {
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
return ""
}
return id
}

View File

@@ -4,6 +4,7 @@
package workspace
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
@@ -452,6 +453,9 @@ func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.Workspace
func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) {
root := fs.filesDir(w)
// v0.27.0: Load .gitignore patterns from workspace root
ignorePatterns := loadGitignore(root)
// Walk FS and collect all paths
fsPaths := make(map[string]os.FileInfo)
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
@@ -467,6 +471,15 @@ func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, remove
}
// Normalize to forward slashes
rel = filepath.ToSlash(rel)
// v0.27.0: Skip gitignored paths
if matchesGitignore(rel, info.IsDir(), ignorePatterns) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
fsPaths[rel] = info
return nil
})
@@ -624,3 +637,80 @@ func hashFile(absPath string) (string, error) {
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// ── Gitignore ───────────────────────────────
// v0.27.0: Simple .gitignore parser for workspace indexing.
// Supports glob patterns, directory-only patterns (trailing /),
// negation (!), and comments (#). Does not support ** (double-star).
type gitignorePattern struct {
pattern string
negate bool
dirOnly bool
}
// loadGitignore reads .gitignore from the workspace root.
// Returns nil if the file doesn't exist.
func loadGitignore(root string) []gitignorePattern {
f, err := os.Open(filepath.Join(root, ".gitignore"))
if err != nil {
return nil
}
defer f.Close()
var patterns []gitignorePattern
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' {
continue
}
p := gitignorePattern{}
if line[0] == '!' {
p.negate = true
line = line[1:]
}
if strings.HasSuffix(line, "/") {
p.dirOnly = true
line = strings.TrimSuffix(line, "/")
}
p.pattern = line
patterns = append(patterns, p)
}
// Always ignore .git directory
patterns = append([]gitignorePattern{{pattern: ".git", dirOnly: true}}, patterns...)
return patterns
}
// matchesGitignore checks if a relative path matches any gitignore pattern.
// Last matching pattern wins (same as git semantics).
func matchesGitignore(relPath string, isDir bool, patterns []gitignorePattern) bool {
if len(patterns) == 0 {
return false
}
ignored := false
basename := path.Base(relPath)
for _, p := range patterns {
if p.dirOnly && !isDir {
continue
}
// Match against basename or full relative path
matched := false
if strings.Contains(p.pattern, "/") {
// Pattern with slash — match against full path
matched, _ = filepath.Match(p.pattern, relPath)
} else {
// Pattern without slash — match against basename
matched, _ = filepath.Match(p.pattern, basename)
if !matched {
// Also try against full path for prefix directory matches
matched, _ = filepath.Match(p.pattern, relPath)
}
}
if matched {
ignored = !p.negate
}
}
return ignored
}