Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package main

import (
"database/sql"
"fmt"
"log"
"net/http"
"os"
Expand Down Expand Up @@ -250,10 +250,35 @@ func main() {
func runMigrations(db *database.DB) error {
log.Println("Running database migrations...")

// Compile regex pattern once for efficiency
// Only process numbered migrations (e.g., 001_init.sql, 002_sample_data.sql)
// Skip helper scripts like bootstrap_existing_db.sql
migrationPattern := regexp.MustCompile(`^\d{3}_.*\.sql$`)
// Acquire advisory lock to prevent concurrent migrations
// Use a fixed integer key for migrations lock (hash of "agent-shaker-migrations")
const migrationLockKey = 918273645

// Try to acquire advisory lock (non-blocking)
var lockAcquired bool
err := db.QueryRow("SELECT pg_try_advisory_lock($1)", migrationLockKey).Scan(&lockAcquired)
if err != nil {
return fmt.Errorf("failed to acquire migration lock: %w", err)
}

if !lockAcquired {
log.Println("Another instance is running migrations, waiting...")
// Block until we can acquire the lock
_, err = db.Exec("SELECT pg_advisory_lock($1)", migrationLockKey)
if err != nil {
return fmt.Errorf("failed to wait for migration lock: %w", err)
}
}

// Ensure we release the lock when done
defer func() {
_, err := db.Exec("SELECT pg_advisory_unlock($1)", migrationLockKey)
if err != nil {
log.Printf("Warning: failed to release migration lock: %v", err)
}
}()

log.Println("Migration lock acquired, proceeding...")

// Create migrations tracking table if it doesn't exist
createTableSQL := `
Expand Down Expand Up @@ -291,14 +316,21 @@ func runMigrations(db *database.DB) error {

// Apply pending migrations in order
appliedCount := 0
migrationPattern := regexp.MustCompile(`^\d`)

for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}

// Skip non-numbered migration files (e.g., bootstrap scripts)
// Skip bootstrap and helper files (only process files starting with a digit)
if !migrationPattern.MatchString(entry.Name()) {
log.Printf("Skipping non-numbered migration file: %s", entry.Name())
log.Printf("Skipping non-migration file: %s", entry.Name())
continue
}

// Skip if already applied
if appliedMigrations[entry.Name()] {
continue
}

Expand Down
16 changes: 7 additions & 9 deletions internal/a2a/models/agent_card.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,14 @@ func (a *AgentCard) UnmarshalJSON(data []byte) error {

// Check if capabilities is present and try legacy formats first
var hasLegacyCap bool
var legacyCapabilities []Capability
var legacyCapabilitiesData any
if capRaw, exists := raw["capabilities"]; exists {
// Try legacy array format []Capability
var legacyCaps []Capability
if err := json.Unmarshal(capRaw, &legacyCaps); err == nil && len(legacyCaps) > 0 {
// Convert legacy array to new Capabilities object structure
// Store in a temporary variable to preserve across struct assignment
legacyCapabilities = legacyCaps
// Store in metadata for backward compatibility
legacyCapabilitiesData = legacyCaps
hasLegacyCap = true
} else if err == nil {
// Empty array, still legacy format
Expand Down Expand Up @@ -145,8 +145,7 @@ func (a *AgentCard) UnmarshalJSON(data []byte) error {
Description: description,
})
}
// Store in a temporary variable to preserve across struct assignment
legacyCapabilities = legacyCaps
legacyCapabilitiesData = legacyCaps
hasLegacyCap = true
}
} else {
Expand All @@ -173,13 +172,12 @@ func (a *AgentCard) UnmarshalJSON(data []byte) error {
return err
}
*a = AgentCard(*aux)

// Restore legacy capabilities after struct assignment
if len(legacyCapabilities) > 0 {
// Re-attach legacy capabilities after unmarshaling
if legacyCapabilitiesData != nil {
if a.Metadata == nil {
a.Metadata = make(map[string]any)
}
a.Metadata["legacyCapabilities"] = legacyCapabilities
a.Metadata["legacyCapabilities"] = legacyCapabilitiesData
}
} else {
// New schema format, unmarshal normally
Expand Down
55 changes: 16 additions & 39 deletions internal/handlers/standups.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,55 +235,31 @@ func (h *StandupHandler) UpdateStandup(w http.ResponseWriter, r *http.Request) {
return
}

result, err := h.db.Exec(`
// Use RETURNING to get the updated standup in a single query
var standup models.DailyStandup
err = h.db.QueryRow(`
UPDATE daily_standups
SET did = $1, doing = $2, done = $3, blockers = $4, challenges = $5, references = $6, updated_at = $7
WHERE id = $8
`, req.Did, req.Doing, req.Done, req.Blockers, req.Challenges, req.References, time.Now(), id)
RETURNING id, agent_id, project_id, standup_date, did, doing, done, blockers, challenges, references, created_at, updated_at
`, req.Did, req.Doing, req.Done, req.Blockers, req.Challenges, req.References, time.Now(), id).Scan(
&standup.ID, &standup.AgentID, &standup.ProjectID, &standup.StandupDate,
&standup.Did, &standup.Doing, &standup.Done, &standup.Blockers, &standup.Challenges,
&standup.References, &standup.CreatedAt, &standup.UpdatedAt,
)

if err != nil {
if err.Error() == "sql: no rows in result set" {
http.Error(w, "Standup not found", http.StatusNotFound)
return
}
http.Error(w, "Failed to update standup", http.StatusInternalServerError)
return
}

rowsAffected, err := result.RowsAffected()
if err != nil {
http.Error(w, "Failed to check update result", http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
http.Error(w, "Standup not found", http.StatusNotFound)
return
}

// Get updated standup
var s models.StandupWithAgent
err = h.db.QueryRow(`
SELECT s.id, s.agent_id, s.project_id, s.standup_date, s.did, s.doing, s.done,
s.blockers, s.challenges, s.references, s.created_at, s.updated_at,
a.name as agent_name, a.role as agent_role, a.team as agent_team
FROM daily_standups s
INNER JOIN agents a ON s.agent_id = a.id
WHERE s.id = $1
`, id).Scan(
&s.ID, &s.AgentID, &s.ProjectID, &s.StandupDate, &s.Did, &s.Doing, &s.Done,
&s.Blockers, &s.Challenges, &s.References, &s.CreatedAt, &s.UpdatedAt,
&s.AgentName, &s.AgentRole, &s.AgentTeam,
)

if err == sql.ErrNoRows {
http.Error(w, "Standup not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "Failed to retrieve standup", http.StatusInternalServerError)
return
}

// Broadcast standup update via WebSocket
h.hub.BroadcastToProject(s.ProjectID, "standup_update", s)

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(standup)
}

// DeleteStandup deletes a standup entry
Expand Down Expand Up @@ -311,6 +287,7 @@ func (h *StandupHandler) DeleteStandup(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Standup not found", http.StatusNotFound)
return
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Standup deleted successfully"})
}
Expand Down
7 changes: 4 additions & 3 deletions scripts/bootstrap-migrations.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ param(

if (-not $DatabaseUrl) {
$DatabaseUrl = "postgres://mcp:secret@localhost:5433/mcp_tracker?sslmode=disable"
$sanitizedDatabaseUrl = $DatabaseUrl
# Sanitize the URL before logging (mask password)
$sanitizedUrl = $DatabaseUrl
if ($DatabaseUrl -match '^(postgres://[^:]+:)[^@]+(@.*)$') {
$sanitizedDatabaseUrl = $matches[1] + '****' + $matches[2]
$sanitizedUrl = $matches[1] + '****' + $matches[2]
}
Write-Host "Using default DATABASE_URL: $sanitizedDatabaseUrl" -ForegroundColor Yellow
Write-Host "Using default DATABASE_URL: $sanitizedUrl" -ForegroundColor Yellow
}

Write-Host ""
Expand Down
Binary file added server
Binary file not shown.
11 changes: 9 additions & 2 deletions web/src/components/ReassignModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,18 @@ export default {
return
}

isSubmitting.value = true
emit('reassign', {
taskId: props.task.id,
agentId: selectedAgentId.value
agentId: selectedAgentId.value,
onComplete: () => {
isSubmitting.value = false
selectedAgentId.value = ''
},
onError: () => {
isSubmitting.value = false
}
})
selectedAgentId.value = ''
}

return {
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/StandupModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white border-b px-6 py-4 flex justify-between items-center">
<h2 class="text-2xl font-bold text-gray-900">{{ isEditing ? 'Update Daily Standup' : 'Submit Daily Standup' }}</h2>
<button @click="handleClose" class="text-gray-400 hover:text-gray-600 text-2xl">&times;</button>
<button @click="handleClose" class="text-gray-400 hover:text-gray-600 text-2xl" aria-label="Close standup modal">&times;</button>
</div>

<form @submit.prevent="handleSubmit" class="p-6 space-y-6">
Expand Down
3 changes: 3 additions & 0 deletions web/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export default {
updateTaskStatus(id, status) {
return api.put(`/tasks/${id}/status`, { status })
},
reassignTask(id, assignedTo) {
return api.put(`/tasks/${id}/reassign`, { assigned_to: assignedTo })
},
deleteTask(id) {
return api.delete(`/tasks/${id}`)
},
Expand Down
21 changes: 18 additions & 3 deletions web/src/views/ProjectDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ import McpSetupModal from '../components/McpSetupModal.vue'
import { formatDate, getUniqueTags } from '../utils/formatters'
import { getAgentName, getTaskTitle, filterContexts } from '../utils/dataHelpers'
import { useMcpSetup, downloadFile, downloadAllMcpFiles } from '../composables/useMcpSetup'
import api from '../services/api'

export default {
name: 'ProjectDetail',
Expand Down Expand Up @@ -981,16 +982,30 @@ get_project_contexts() {

isReassigningTask.value = true
try {
await taskStore.reassignTask(reassignmentData.taskId, reassignmentData.agentId)
const updatedTask = await api.reassignTask(reassignmentData.taskId, reassignmentData.agentId)

// Update the task store with the new task data
const taskIndex = taskStore.tasks.findIndex(t => t.id === updatedTask.id)
if (taskIndex !== -1) {
taskStore.tasks[taskIndex] = updatedTask
}

showReassignTaskModal.value = false
reassigningTask.value = null
alert('Task reassigned successfully!')

// Call the completion callback if provided
if (reassignmentData.onComplete) {
reassignmentData.onComplete()
}
} catch (error) {
console.error('Failed to reassign task:', error)
alert('Failed to reassign task. Please try again.')
} finally {
isReassigningTask.value = false

// Call the error callback if provided
if (reassignmentData.onError) {
reassignmentData.onError()
}
}
}

Expand Down
20 changes: 19 additions & 1 deletion web/src/views/Standups.vue
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export default {

const handleRefresh = async () => {
isRefreshing.value = true
error.value = null // Clear error before refresh
try {
await fetchStandups()
} catch (err) {
Expand Down Expand Up @@ -332,7 +333,24 @@ export default {
}

const formatDate = (dateString) => {
const date = new Date(dateString)
// Treat as date-only value to avoid timezone shifts
if (!dateString) return ''

const dateOnly = dateString.includes('T') ? dateString.split('T')[0] : dateString
const parts = dateOnly.split('-')

if (parts.length !== 3) {
// Fallback to standard parsing if format is unexpected
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}

const [year, month, day] = parts.map(Number)
const date = new Date(year, month - 1, day) // Use local timezone with specific date parts
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
Expand Down