diff --git a/cmd/server/main.go b/cmd/server/main.go index 49d3f4d..cc0fb7a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,7 +1,7 @@ package main import ( - "database/sql" + "fmt" "log" "net/http" "os" @@ -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 := ` @@ -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 } diff --git a/internal/a2a/models/agent_card.go b/internal/a2a/models/agent_card.go index 97586d0..2ae6ab0 100644 --- a/internal/a2a/models/agent_card.go +++ b/internal/a2a/models/agent_card.go @@ -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 @@ -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 { @@ -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 diff --git a/internal/handlers/standups.go b/internal/handlers/standups.go index 932bf95..f191b3f 100644 --- a/internal/handlers/standups.go +++ b/internal/handlers/standups.go @@ -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 @@ -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"}) } diff --git a/scripts/bootstrap-migrations.ps1 b/scripts/bootstrap-migrations.ps1 index d0b6257..6cb3603 100644 --- a/scripts/bootstrap-migrations.ps1 +++ b/scripts/bootstrap-migrations.ps1 @@ -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 "" diff --git a/server b/server new file mode 100755 index 0000000..1ddc144 Binary files /dev/null and b/server differ diff --git a/web/src/components/ReassignModal.vue b/web/src/components/ReassignModal.vue index 109f4c0..8767f2c 100644 --- a/web/src/components/ReassignModal.vue +++ b/web/src/components/ReassignModal.vue @@ -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 { diff --git a/web/src/components/StandupModal.vue b/web/src/components/StandupModal.vue index 2f04262..7ffe3f9 100644 --- a/web/src/components/StandupModal.vue +++ b/web/src/components/StandupModal.vue @@ -3,7 +3,7 @@

{{ isEditing ? 'Update Daily Standup' : 'Submit Daily Standup' }}

- +
diff --git a/web/src/services/api.js b/web/src/services/api.js index 298bc87..3387540 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -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}`) }, diff --git a/web/src/views/ProjectDetail.vue b/web/src/views/ProjectDetail.vue index e254565..906dd2f 100644 --- a/web/src/views/ProjectDetail.vue +++ b/web/src/views/ProjectDetail.vue @@ -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', @@ -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() + } } } diff --git a/web/src/views/Standups.vue b/web/src/views/Standups.vue index a6b237e..6b34606 100644 --- a/web/src/views/Standups.vue +++ b/web/src/views/Standups.vue @@ -282,6 +282,7 @@ export default { const handleRefresh = async () => { isRefreshing.value = true + error.value = null // Clear error before refresh try { await fetchStandups() } catch (err) { @@ -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',