enum AdminCommandType {
    ADMIN_UNINSTALL = 0;            // Uninstall agent and remove from startup
    ADMIN_RESTART = 1;              // Restart the agent process
    ADMIN_UPDATE = 2;               // Download and install update (future)
}

enum AdminCommandType {
    ADMIN_UNINSTALL = 0;            // Uninstall agent and remove from startup
    ADMIN_RESTART = 1;              // Restart the agent process
    ADMIN_UPDATE = 2;               // Download and install update
}

// ============================================================================
// Auto-Update Messages
// ============================================================================

// Update command details (sent with AdminCommand or standalone)
message UpdateInfo {
    string version = 1;             // Target version (e.g., "0.2.0")
    string download_url = 2;        // HTTPS URL to download new binary
    string checksum_sha256 = 3;     // SHA-256 hash for verification
    bool mandatory = 4;             // If true, agent must update immediately
}

// Update status report (agent -> server)
message UpdateStatus {
    string current_version = 1;     // Current running version
    UpdateState state = 2;          // Current update state
    string error_message = 3;       // Error details if state is FAILED
    int32 progress_percent = 4;     // Download progress (0-100)
}

enum UpdateState {
    UPDATE_IDLE = 0;                // No update in progress
    UPDATE_CHECKING = 1;            // Checking for updates
    UPDATE_DOWNLOADING = 2;         // Downloading new binary
    UPDATE_VERIFYING = 3;           // Verifying checksum
    UPDATE_INSTALLING = 4;          // Installing (rename/copy)
    UPDATE_RESTARTING = 5;          // About to restart
    UPDATE_COMPLETE = 6;            // Update successful (after restart)
    UPDATE_FAILED = 7;              // Update failed
}


The file C:\Users\MikeSwanson\Claude\guru-connect\proto\guruconnect.proto has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
   284→    AdminCommandType command = 1;
   285→    string reason = 2;              // Why the command was issued
   286→}
   287→
   288→enum AdminCommandType {
   289→    ADMIN_UNINSTALL = 0;            // Uninstall agent and remove from startup
   290→    ADMIN_RESTART = 1;              // Restart the agent process
   291→    ADMIN_UPDATE = 2;               // Download and install update
   292→}
   293→
   294→// ============================================================================
   295→// Auto-Update Messages
   296→// ============================================================================
   297→
   298→// Update command details (sent with AdminCommand or standalone)
   299→message UpdateInfo {
   300→    string version = 1;             // Target version (e.g., "0.2.0")
   301→    string download_url = 2;        // HTTPS URL to download new binary
   302→    string checksum_sha256 = 3;     // SHA-256 hash for verification
   303→    bool mandatory = 4;             // If true, agent must update immediately
   304→}
   305→
   306→// Update status report (agent -> server)
   307→message UpdateStatus {
   308→    string current_version = 1;     // Current running version
   309→    UpdateState state = 2;          // Current update state
   310→    string error_message = 3;       // Error details if state is FAILED
   311→    int32 progress_percent = 4;     // Download progress (0-100)
   312→}
   313→
   314→enum UpdateState {
   315→    UPDATE_IDLE = 0;                // No update in progress
   316→    UPDATE_CHECKING = 1;            // Checking for updates
   317→    UPDATE_DOWNLOADING = 2;         // Downloading new binary
   318→    UPDATE_VERIFYING = 3;           // Verifying checksum
   319→    UPDATE_INSTALLING = 4;          // Installing (rename/copy)
   320→    UPDATE_RESTARTING = 5;          // About to restart
   321→    UPDATE_COMPLETE = 6;            // Update successful (after restart)
   322→    UPDATE_FAILED = 7;              // Update failed
   323→}
   324→
   325→// ============================================================================
   326→// Top-Level Message Wrapper
   327→// ============================================================================

enum AdminCommandType {
    ADMIN_UNINSTALL = 0;            // Uninstall agent and remove from startup
    ADMIN_RESTART = 1;              // Restart the agent process
    ADMIN_UPDATE = 2;               // Download and install update
}

// ============================================================================
// Auto-Update Messages
// ============================================================================

// Update command details (sent with AdminCommand or standalone)
message UpdateInfo {
    string version = 1;             // Target version (e.g., "0.2.0")
    string download_url = 2;        // HTTPS URL to download new binary
    string checksum_sha256 = 3;     // SHA-256 hash for verification
    bool mandatory = 4;             // If true, agent must update immediately
}

// Update status report (agent -> server)
message UpdateStatus {
    string current_version = 1;     // Current running version
    UpdateState state = 2;          // Current update state
    string error_message = 3;       // Error details if state is FAILED
    int32 progress_percent = 4;     // Download progress (0-100)
}

enum UpdateState {
    UPDATE_IDLE = 0;                // No update in progress
    UPDATE_CHECKING = 1;            // Checking for updates
    UPDATE_DOWNLOADING = 2;         // Downloading new binary
    UPDATE_VERIFYING = 3;           // Verifying checksum
    UPDATE_INSTALLING = 4;          // Installing (rename/copy)
    UPDATE_RESTARTING = 5;          // About to restart
    UPDATE_COMPLETE = 6;            // Update successful (after restart)
    UPDATE_FAILED = 7;              // Update failed
}

syntax = "proto3";
package guruconnect;

// ============================================================================
// Session Management
// ============================================================================

message SessionRequest {
    string agent_id = 1;
    string session_token = 2;
    SessionType session_type = 3;
    string client_version = 4;
}

message SessionResponse {
    bool success = 1;
    string session_id = 2;
    string error = 3;
    DisplayInfo display_info = 4;
}

enum SessionType {
    SCREEN_CONTROL = 0;
    VIEW_ONLY = 1;
    BACKSTAGE = 2;
    FILE_TRANSFER = 3;
}

// ============================================================================
// Display Information
// ============================================================================

message DisplayInfo {
    repeated Display displays = 1;
    int32 primary_display = 2;
}

message Display {
    int32 id = 1;
    string name = 2;
    int32 x = 3;
    int32 y = 4;
    int32 width = 5;
    int32 height = 6;
    bool is_primary = 7;
}

message SwitchDisplay {
    int32 display_id = 1;
}

// ============================================================================
// Video Frames
// ============================================================================

message VideoFrame {
    int64 timestamp = 1;
    int32 display_id = 2;
    int32 sequence = 3;

    oneof encoding {
        RawFrame raw = 10;
        EncodedFrame vp9 = 11;
        EncodedFrame h264 = 12;
        EncodedFrame h265 = 13;
    }
}

message RawFrame {
    int32 width = 1;
    int32 height = 2;
    bytes data = 3;             // Zstd compressed BGRA
    bool compressed = 4;
    repeated DirtyRect dirty_rects = 5;
    bool is_keyframe = 6;       // Full frame vs incremental
}

message DirtyRect {
    int32 x = 1;
    int32 y = 2;
    int32 width = 3;
    int32 height = 4;
}

message EncodedFrame {
    bytes data = 1;
    bool keyframe = 2;
    int64 pts = 3;
    int64 dts = 4;
}

message VideoAck {
    int32 sequence = 1;
    int64 timestamp = 2;
}

// ============================================================================
// Cursor
// ============================================================================

message CursorShape {
    uint64 id = 1;
    int32 hotspot_x = 2;
    int32 hotspot_y = 3;
    int32 width = 4;
    int32 height = 5;
    bytes data = 6;             // BGRA bitmap
}

message CursorPosition {
    int32 x = 1;
    int32 y = 2;
    bool visible = 3;
}

// ============================================================================
// Input Events
// ============================================================================

message MouseEvent {
    int32 x = 1;
    int32 y = 2;
    MouseButtons buttons = 3;
    int32 wheel_delta_x = 4;
    int32 wheel_delta_y = 5;
    MouseEventType event_type = 6;
}

enum MouseEventType {
    MOUSE_MOVE = 0;
    MOUSE_DOWN = 1;
    MOUSE_UP = 2;
    MOUSE_WHEEL = 3;
}

message MouseButtons {
    bool left = 1;
    bool right = 2;
    bool middle = 3;
    bool x1 = 4;
    bool x2 = 5;
}

message KeyEvent {
    bool down = 1;              // true = key down, false = key up
    KeyEventType key_type = 2;
    uint32 vk_code = 3;         // Virtual key code (Windows VK_*)
    uint32 scan_code = 4;       // Hardware scan code
    string unicode = 5;         // Unicode character (for text input)
    Modifiers modifiers = 6;
}

enum KeyEventType {
    KEY_VK = 0;                 // Virtual key code
    KEY_SCAN = 1;               // Scan code
    KEY_UNICODE = 2;            // Unicode character
}

message Modifiers {
    bool ctrl = 1;
    bool alt = 2;
    bool shift = 3;
    bool meta = 4;              // Windows key
    bool caps_lock = 5;
    bool num_lock = 6;
}

message SpecialKeyEvent {
    SpecialKey key = 1;
}

enum SpecialKey {
    CTRL_ALT_DEL = 0;
    LOCK_SCREEN = 1;
    PRINT_SCREEN = 2;
}

// ============================================================================
// Clipboard
// ============================================================================

message ClipboardData {
    ClipboardFormat format = 1;
    bytes data = 2;
    string mime_type = 3;
}

enum ClipboardFormat {
    CLIPBOARD_TEXT = 0;
    CLIPBOARD_HTML = 1;
    CLIPBOARD_RTF = 2;
    CLIPBOARD_IMAGE = 3;
    CLIPBOARD_FILES = 4;
}

message ClipboardRequest {
    // Request current clipboard content
}

// ============================================================================
// Quality Control
// ============================================================================

message QualitySettings {
    QualityPreset preset = 1;
    int32 custom_fps = 2;       // 1-60
    int32 custom_bitrate = 3;   // kbps
    CodecPreference codec = 4;
}

enum QualityPreset {
    QUALITY_AUTO = 0;
    QUALITY_LOW = 1;            // Low bandwidth
    QUALITY_BALANCED = 2;
    QUALITY_HIGH = 3;           // Best quality
}

enum CodecPreference {
    CODEC_AUTO = 0;
    CODEC_RAW = 1;              // Raw + Zstd (LAN)
    CODEC_VP9 = 2;
    CODEC_H264 = 3;
    CODEC_H265 = 4;
}

message LatencyReport {
    int64 rtt_ms = 1;
    int32 fps = 2;
    int32 bitrate_kbps = 3;
}

// ============================================================================
// Chat Messages
// ============================================================================

message ChatMessage {
    string id = 1;              // Unique message ID
    string sender = 2;          // "technician" or "client"
    string content = 3;         // Message text
    int64 timestamp = 4;        // Unix timestamp
}

// ============================================================================
// Control Messages
// ============================================================================

message Heartbeat {
    int64 timestamp = 1;
}

message HeartbeatAck {
    int64 client_timestamp = 1;
    int64 server_timestamp = 2;
}

message Disconnect {
    string reason = 1;
}

// Server commands agent to start streaming video
message StartStream {
    string viewer_id = 1;           // ID of viewer requesting stream
    int32 display_id = 2;           // Which display to stream (0 = primary)
}

// Server commands agent to stop streaming
message StopStream {
    string viewer_id = 1;           // Which viewer disconnected
}

// Agent reports its status periodically when idle
message AgentStatus {
    string hostname = 1;
    string os_version = 2;
    bool is_elevated = 3;
    int64 uptime_secs = 4;
    int32 display_count = 5;
    bool is_streaming = 6;
    string agent_version = 7;           // Agent version (e.g., "0.1.0-abc123")
}

// Server commands agent to uninstall itself
message AdminCommand {
    AdminCommandType command = 1;
    string reason = 2;              // Why the command was issued
}

enum AdminCommandType {
    ADMIN_UNINSTALL = 0;            // Uninstall agent and remove from startup
    ADMIN_RESTART = 1;              // Restart the agent process
    ADMIN_UPDATE = 2;               // Download and install update (future)
}

// ============================================================================
// Top-Level Message Wrapper
// ============================================================================

message Message {
    oneof payload {
        // Session
        SessionRequest session_request = 1;
        SessionResponse session_response = 2;

        // Video
        VideoFrame video_frame = 10;
        VideoAck video_ack = 11;
        SwitchDisplay switch_display = 12;

        // Cursor
        CursorShape cursor_shape = 15;
        CursorPosition cursor_position = 16;

        // Input
        MouseEvent mouse_event = 20;
        KeyEvent key_event = 21;
        SpecialKeyEvent special_key = 22;

        // Clipboard
        ClipboardData clipboard_data = 30;
        ClipboardRequest clipboard_request = 31;

        // Quality
        QualitySettings quality_settings = 40;
        LatencyReport latency_report = 41;

        // Control
        Heartbeat heartbeat = 50;
        HeartbeatAck heartbeat_ack = 51;
        Disconnect disconnect = 52;
        StartStream start_stream = 53;
        StopStream stop_stream = 54;
        AgentStatus agent_status = 55;

        // Chat
        ChatMessage chat_message = 60;

        // Admin commands (server -> agent)
        AdminCommand admin_command = 70;
    }
}

-    ADMIN_UPDATE = 2;               // Download and install update (future)

+    ADMIN_UPDATE = 2;               // Download and install update

+// Update command details (sent with AdminCommand or standalone)

+    string version = 1;             // Target version (e.g., "0.2.0")

+    string download_url = 2;        // HTTPS URL to download new binary

+    string checksum_sha256 = 3;     // SHA-256 hash for verification

+    bool mandatory = 4;             // If true, agent must update immediately

+// Update status report (agent -> server)

+    string current_version = 1;     // Current running version

+    UpdateState state = 2;          // Current update state

+    string error_message = 3;       // Error details if state is FAILED

+    int32 progress_percent = 4;     // Download progress (0-100)

+    UPDATE_IDLE = 0;                // No update in progress

+    UPDATE_CHECKING = 1;            // Checking for updates

+    UPDATE_DOWNLOADING = 2;         // Downloading new binary

+    UPDATE_VERIFYING = 3;           // Verifying checksum

+    UPDATE_INSTALLING = 4;          // Installing (rename/copy)

+    UPDATE_RESTARTING = 5;          // About to restart

+    UPDATE_COMPLETE = 6;            // Update successful (after restart)

+    UPDATE_FAILED = 7;              // Update failed


Now I need to add update_info and update_status to the Message wrapper. Let me find the Message definition and add these fields.


Now add the new message types to the Message wrapper:


Edit

        // Admin commands (server -> agent)
        AdminCommand admin_command = 70;
    }
}

        // Admin commands (server -> agent)
        AdminCommand admin_command = 70;

        // Auto-update messages
        UpdateInfo update_info = 75;        // Server -> Agent: update available
        UpdateStatus update_status = 76;    // Agent -> Server: update progress
    }
}


The file C:\Users\MikeSwanson\Claude\guru-connect\proto\guruconnect.proto has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
   364→
   365→        // Chat
   366→        ChatMessage chat_message = 60;
   367→
   368→        // Admin commands (server -> agent)
   369→        AdminCommand admin_command = 70;
   370→
   371→        // Auto-update messages
   372→        UpdateInfo update_info = 75;        // Server -> Agent: update available
   373→        UpdateStatus update_status = 76;    // Agent -> Server: update progress
   374→    }
   375→}
   376→

        // Admin commands (server -> agent)
        AdminCommand admin_command = 70;

        // Auto-update messages
        UpdateInfo update_info = 75;        // Server -> Agent: update available
        UpdateStatus update_status = 76;    // Agent -> Server: update progress
    }
}

syntax = "proto3";
package guruconnect;

// ============================================================================
// Session Management
// ============================================================================

message SessionRequest {
    string agent_id = 1;
    string session_token = 2;
    SessionType session_type = 3;
    string client_version = 4;
}

message SessionResponse {
    bool success = 1;
    string session_id = 2;
    string error = 3;
    DisplayInfo display_info = 4;
}

enum SessionType {
    SCREEN_CONTROL = 0;
    VIEW_ONLY = 1;
    BACKSTAGE = 2;
    FILE_TRANSFER = 3;
}

// ============================================================================
// Display Information
// ============================================================================

message DisplayInfo {
    repeated Display displays = 1;
    int32 primary_display = 2;
}

message Display {
    int32 id = 1;
    string name = 2;
    int32 x = 3;
    int32 y = 4;
    int32 width = 5;
    int32 height = 6;
    bool is_primary = 7;
}

message SwitchDisplay {
    int32 display_id = 1;
}

// ============================================================================
// Video Frames
// ============================================================================

message VideoFrame {
    int64 timestamp = 1;
    int32 display_id = 2;
    int32 sequence = 3;

    oneof encoding {
        RawFrame raw = 10;
        EncodedFrame vp9 = 11;
        EncodedFrame h264 = 12;
        EncodedFrame h265 = 13;
    }
}

message RawFrame {
    int32 width = 1;
    int32 height = 2;
    bytes data = 3;             // Zstd compressed BGRA
    bool compressed = 4;
    repeated DirtyRect dirty_rects = 5;
    bool is_keyframe = 6;       // Full frame vs incremental
}

message DirtyRect {
    int32 x = 1;
    int32 y = 2;
    int32 width = 3;
    int32 height = 4;
}

message EncodedFrame {
    bytes data = 1;
    bool keyframe = 2;
    int64 pts = 3;
    int64 dts = 4;
}

message VideoAck {
    int32 sequence = 1;
    int64 timestamp = 2;
}

// ============================================================================
// Cursor
// ============================================================================

message CursorShape {
    uint64 id = 1;
    int32 hotspot_x = 2;
    int32 hotspot_y = 3;
    int32 width = 4;
    int32 height = 5;
    bytes data = 6;             // BGRA bitmap
}

message CursorPosition {
    int32 x = 1;
    int32 y = 2;
    bool visible = 3;
}

// ============================================================================
// Input Events
// ============================================================================

message MouseEvent {
    int32 x = 1;
    int32 y = 2;
    MouseButtons buttons = 3;
    int32 wheel_delta_x = 4;
    int32 wheel_delta_y = 5;
    MouseEventType event_type = 6;
}

enum MouseEventType {
    MOUSE_MOVE = 0;
    MOUSE_DOWN = 1;
    MOUSE_UP = 2;
    MOUSE_WHEEL = 3;
}

message MouseButtons {
    bool left = 1;
    bool right = 2;
    bool middle = 3;
    bool x1 = 4;
    bool x2 = 5;
}

message KeyEvent {
    bool down = 1;              // true = key down, false = key up
    KeyEventType key_type = 2;
    uint32 vk_code = 3;         // Virtual key code (Windows VK_*)
    uint32 scan_code = 4;       // Hardware scan code
    string unicode = 5;         // Unicode character (for text input)
    Modifiers modifiers = 6;
}

enum KeyEventType {
    KEY_VK = 0;                 // Virtual key code
    KEY_SCAN = 1;               // Scan code
    KEY_UNICODE = 2;            // Unicode character
}

message Modifiers {
    bool ctrl = 1;
    bool alt = 2;
    bool shift = 3;
    bool meta = 4;              // Windows key
    bool caps_lock = 5;
    bool num_lock = 6;
}

message SpecialKeyEvent {
    SpecialKey key = 1;
}

enum SpecialKey {
    CTRL_ALT_DEL = 0;
    LOCK_SCREEN = 1;
    PRINT_SCREEN = 2;
}

// ============================================================================
// Clipboard
// ============================================================================

message ClipboardData {
    ClipboardFormat format = 1;
    bytes data = 2;
    string mime_type = 3;
}

enum ClipboardFormat {
    CLIPBOARD_TEXT = 0;
    CLIPBOARD_HTML = 1;
    CLIPBOARD_RTF = 2;
    CLIPBOARD_IMAGE = 3;
    CLIPBOARD_FILES = 4;
}

message ClipboardRequest {
    // Request current clipboard content
}

// ============================================================================
// Quality Control
// ============================================================================

message QualitySettings {
    QualityPreset preset = 1;
    int32 custom_fps = 2;       // 1-60
    int32 custom_bitrate = 3;   // kbps
    CodecPreference codec = 4;
}

enum QualityPreset {
    QUALITY_AUTO = 0;
    QUALITY_LOW = 1;            // Low bandwidth
    QUALITY_BALANCED = 2;
    QUALITY_HIGH = 3;           // Best quality
}

enum CodecPreference {
    CODEC_AUTO = 0;
    CODEC_RAW = 1;              // Raw + Zstd (LAN)
    CODEC_VP9 = 2;
    CODEC_H264 = 3;
    CODEC_H265 = 4;
}

message LatencyReport {
    int64 rtt_ms = 1;
    int32 fps = 2;
    int32 bitrate_kbps = 3;
}

// ============================================================================
// Chat Messages
// ============================================================================

message ChatMessage {
    string id = 1;              // Unique message ID
    string sender = 2;          // "technician" or "client"
    string content = 3;         // Message text
    int64 timestamp = 4;        // Unix timestamp
}

// ============================================================================
// Control Messages
// ============================================================================

message Heartbeat {
    int64 timestamp = 1;
}

message HeartbeatAck {
    int64 client_timestamp = 1;
    int64 server_timestamp = 2;
}

message Disconnect {
    string reason = 1;
}

// Server commands agent to start streaming video
message StartStream {
    string viewer_id = 1;           // ID of viewer requesting stream
    int32 display_id = 2;           // Which display to stream (0 = primary)
}

// Server commands agent to stop streaming
message StopStream {
    string viewer_id = 1;           // Which viewer disconnected
}

// Agent reports its status periodically when idle
message AgentStatus {
    string hostname = 1;
    string os_version = 2;
    bool is_elevated = 3;
    int64 uptime_secs = 4;
    int32 display_count = 5;
    bool is_streaming = 6;
    string agent_version = 7;           // Agent version (e.g., "0.1.0-abc123")
}

// Server commands agent to uninstall itself
message AdminCommand {
    AdminCommandType command = 1;
    string reason = 2;              // Why the command was issued
}

enum AdminCommandType {
    ADMIN_UNINSTALL = 0;            // Uninstall agent and remove from startup
    ADMIN_RESTART = 1;              // Restart the agent process
    ADMIN_UPDATE = 2;               // Download and install update
}

// ============================================================================
// Auto-Update Messages
// ============================================================================

// Update command details (sent with AdminCommand or standalone)
message UpdateInfo {
    string version = 1;             // Target version (e.g., "0.2.0")
    string download_url = 2;        // HTTPS URL to download new binary
    string checksum_sha256 = 3;     // SHA-256 hash for verification
    bool mandatory = 4;             // If true, agent must update immediately
}

// Update status report (agent -> server)
message UpdateStatus {
    string current_version = 1;     // Current running version
    UpdateState state = 2;          // Current update state
    string error_message = 3;       // Error details if state is FAILED
    int32 progress_percent = 4;     // Download progress (0-100)
}

enum UpdateState {
    UPDATE_IDLE = 0;                // No update in progress
    UPDATE_CHECKING = 1;            // Checking for updates
    UPDATE_DOWNLOADING = 2;         // Downloading new binary
    UPDATE_VERIFYING = 3;           // Verifying checksum
    UPDATE_INSTALLING = 4;          // Installing (rename/copy)
    UPDATE_RESTARTING = 5;          // About to restart
    UPDATE_COMPLETE = 6;            // Update successful (after restart)
    UPDATE_FAILED = 7;              // Update failed
}

// ============================================================================
// Top-Level Message Wrapper
// ============================================================================

message Message {
    oneof payload {
        // Session
        SessionRequest session_request = 1;
        SessionResponse session_response = 2;

        // Video
        VideoFrame video_frame = 10;
        VideoAck video_ack = 11;
        SwitchDisplay switch_display = 12;

        // Cursor
        CursorShape cursor_shape = 15;
        CursorPosition cursor_position = 16;

        // Input
        MouseEvent mouse_event = 20;
        KeyEvent key_event = 21;
        SpecialKeyEvent special_key = 22;

        // Clipboard
        ClipboardData clipboard_data = 30;
        ClipboardRequest clipboard_request = 31;

        // Quality
        QualitySettings quality_settings = 40;
        LatencyReport latency_report = 41;

        // Control
        Heartbeat heartbeat = 50;
        HeartbeatAck heartbeat_ack = 51;
        Disconnect disconnect = 52;
        StartStream start_stream = 53;
        StopStream stop_stream = 54;
        AgentStatus agent_status = 55;

        // Chat
        ChatMessage chat_message = 60;

        // Admin commands (server -> agent)
        AdminCommand admin_command = 70;
    }
}

+        UpdateInfo update_info = 75;        // Server -> Agent: update available

+        UpdateStatus update_status = 76;    // Agent -> Server: update progress


I've updated the protobuf file. Now let me mark this task as complete and move to the next one - the database migration. Let me also update the todos.


TodoWrite

Phase 1: Update proto/guruconnect.proto with UpdateInfo, UpdateStatus, UpdateState

Phase 1: Create database migration for releases table and machine version columns

Phase 2: Create server db/releases.rs - CRUD for releases table

Phase 2: Create server api/releases.rs - Admin endpoints

Phase 2: Add GET /api/version endpoint (unauthenticated)

Phase 2: Add POST /api/machines/{id}/update endpoint

Phase 2: Track agent versions when they connect

Phase 3: Create agent/src/update.rs module

Phase 3: Add periodic update check in session idle loop

Phase 3: Handle ADMIN_UPDATE command in session

Phase 3: Add --post-update flag and cleanup logic

Phase 4: Update dashboard.html with version display and update buttons


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Phase 1: Update proto/guruconnect.proto with UpdateInfo, UpdateStatus, UpdateState

Phase 1: Create database migration for releases table and machine version columns

Phase 2: Create server db/releases.rs - CRUD for releases table

Phase 2: Create server api/releases.rs - Admin endpoints

Phase 2: Add GET /api/version endpoint (unauthenticated)

Phase 2: Add POST /api/machines/{id}/update endpoint

Phase 2: Track agent versions when they connect

Phase 3: Create agent/src/update.rs module

Phase 3: Add periodic update check in session idle loop

Phase 3: Handle ADMIN_UPDATE command in session

Phase 3: Add --post-update flag and cleanup logic

Phase 4: Update dashboard.html with version display and update buttons


Now I need to create the database migration. Let me first check what migrations already exist to determine the correct numbering.


Now let me check the existing migrations to determine the correct numbering:


C:\Users\MikeSwanson\Claude\guru-connect\server\migrations\001_initial_schema.sql
C:\Users\MikeSwanson\Claude\guru-connect\server\migrations\002_user_management.sql


There are already 001 and 002 migrations. I need to create 003_auto_update.sql.


Write

-- Migration: 003_auto_update.sql
-- Purpose: Add auto-update infrastructure (releases table and machine version tracking)

-- ============================================================================
-- Releases Table
-- ============================================================================

-- Track available agent releases
CREATE TABLE IF NOT EXISTS releases (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    version VARCHAR(32) NOT NULL UNIQUE,
    download_url TEXT NOT NULL,
    checksum_sha256 VARCHAR(64) NOT NULL,
    release_notes TEXT,
    is_stable BOOLEAN NOT NULL DEFAULT false,
    is_mandatory BOOLEAN NOT NULL DEFAULT false,
    min_version VARCHAR(32),              -- Minimum version that can update to this
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index for finding latest stable release
CREATE INDEX IF NOT EXISTS idx_releases_stable ON releases(is_stable, created_at DESC);

-- ============================================================================
-- Machine Version Tracking
-- ============================================================================

-- Add version tracking columns to existing machines table
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS agent_version VARCHAR(32);
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS update_status VARCHAR(32);
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS last_update_check TIMESTAMPTZ;

-- Index for finding machines needing updates
CREATE INDEX IF NOT EXISTS idx_machines_version ON connect_machines(agent_version);
CREATE INDEX IF NOT EXISTS idx_machines_update_status ON connect_machines(update_status);


File created successfully at: C:\Users\MikeSwanson\Claude\guru-connect\server\migrations\003_auto_update.sql

-- Migration: 003_auto_update.sql
-- Purpose: Add auto-update infrastructure (releases table and machine version tracking)

-- ============================================================================
-- Releases Table
-- ============================================================================

-- Track available agent releases
CREATE TABLE IF NOT EXISTS releases (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    version VARCHAR(32) NOT NULL UNIQUE,
    download_url TEXT NOT NULL,
    checksum_sha256 VARCHAR(64) NOT NULL,
    release_notes TEXT,
    is_stable BOOLEAN NOT NULL DEFAULT false,
    is_mandatory BOOLEAN NOT NULL DEFAULT false,
    min_version VARCHAR(32),              -- Minimum version that can update to this
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index for finding latest stable release
CREATE INDEX IF NOT EXISTS idx_releases_stable ON releases(is_stable, created_at DESC);

-- ============================================================================
-- Machine Version Tracking
-- ============================================================================

-- Add version tracking columns to existing machines table
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS agent_version VARCHAR(32);
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS update_status VARCHAR(32);
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS last_update_check TIMESTAMPTZ;

-- Index for finding machines needing updates
CREATE INDEX IF NOT EXISTS idx_machines_version ON connect_machines(agent_version);
CREATE INDEX IF NOT EXISTS idx_machines_update_status ON connect_machines(update_status);


Now I've created the migration. Let me mark this task as complete and move to Phase 2 tasks. I'll start with creating the db/releases.rs file.First, let me check the existing db module structure to understand the patterns.


TodoWrite

Phase 1: Update proto/guruconnect.proto with UpdateInfo, UpdateStatus, UpdateState

Phase 1: Create database migration for releases table and machine version columns

Phase 2: Create server db/releases.rs - CRUD for releases table

Phase 2: Create server api/releases.rs - Admin endpoints

Phase 2: Add GET /api/version endpoint (unauthenticated)

Phase 2: Add POST /api/machines/{id}/update endpoint

Phase 2: Track agent versions when they connect

Phase 3: Create agent/src/update.rs module

Phase 3: Add periodic update check in session idle loop

Phase 3: Handle ADMIN_UPDATE command in session

Phase 3: Add --post-update flag and cleanup logic

Phase 4: Update dashboard.html with version display and update buttons


Let me check the existing db module structure to understand the patterns.


Let me look at one of these files to understand the patterns used.


     1→//! Database module for GuruConnect
     2→//!
     3→//! Handles persistence for machines, sessions, and audit logging.
     4→//! Optional - server works without database if DATABASE_URL not set.
     5→
     6→pub mod machines;
     7→pub mod sessions;
     8→pub mod events;
     9→pub mod support_codes;
    10→pub mod users;
    11→
    12→use anyhow::Result;
    13→use sqlx::postgres::PgPoolOptions;
    14→use sqlx::PgPool;
    15→use tracing::info;
    16→
    17→pub use machines::*;
    18→pub use sessions::*;
    19→pub use events::*;
    20→pub use support_codes::*;
    21→pub use users::*;
    22→
    23→/// Database connection pool wrapper
    24→#[derive(Clone)]
    25→pub struct Database {
    26→    pool: PgPool,
    27→}
    28→
    29→impl Database {
    30→    /// Initialize database connection pool
    31→    pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self> {
    32→        info!("Connecting to database...");
    33→        let pool = PgPoolOptions::new()
    34→            .max_connections(max_connections)
    35→            .connect(database_url)
    36→            .await?;
    37→
    38→        info!("Database connection established");
    39→        Ok(Self { pool })
    40→    }
    41→
    42→    /// Run database migrations
    43→    pub async fn migrate(&self) -> Result<()> {
    44→        info!("Running database migrations...");
    45→        sqlx::migrate!("./migrations").run(&self.pool).await?;
    46→        info!("Migrations complete");
    47→        Ok(())
    48→    }
    49→
    50→    /// Get reference to the connection pool
    51→    pub fn pool(&self) -> &PgPool {
    52→        &self.pool
    53→    }
    54→}
    55→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! Database module for GuruConnect
//!
//! Handles persistence for machines, sessions, and audit logging.
//! Optional - server works without database if DATABASE_URL not set.

pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
pub mod users;

use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tracing::info;

pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
pub use users::*;

/// Database connection pool wrapper
#[derive(Clone)]
pub struct Database {
    pool: PgPool,
}

impl Database {
    /// Initialize database connection pool
    pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self> {
        info!("Connecting to database...");
        let pool = PgPoolOptions::new()
            .max_connections(max_connections)
            .connect(database_url)
            .await?;

        info!("Database connection established");
        Ok(Self { pool })
    }

    /// Run database migrations
    pub async fn migrate(&self) -> Result<()> {
        info!("Running database migrations...");
        sqlx::migrate!("./migrations").run(&self.pool).await?;
        info!("Migrations complete");
        Ok(())
    }

    /// Get reference to the connection pool
    pub fn pool(&self) -> &PgPool {
        &self.pool
    }
}


     1→//! Machine/Agent database operations
     2→
     3→use chrono::{DateTime, Utc};
     4→use serde::{Deserialize, Serialize};
     5→use sqlx::PgPool;
     6→use uuid::Uuid;
     7→
     8→/// Machine record from database
     9→#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
    10→pub struct Machine {
    11→    pub id: Uuid,
    12→    pub agent_id: String,
    13→    pub hostname: String,
    14→    pub os_version: Option<String>,
    15→    pub is_elevated: bool,
    16→    pub is_persistent: bool,
    17→    pub first_seen: DateTime<Utc>,
    18→    pub last_seen: DateTime<Utc>,
    19→    pub last_session_id: Option<Uuid>,
    20→    pub status: String,
    21→    pub created_at: DateTime<Utc>,
    22→    pub updated_at: DateTime<Utc>,
    23→}
    24→
    25→/// Get or create a machine by agent_id (upsert)
    26→pub async fn upsert_machine(
    27→    pool: &PgPool,
    28→    agent_id: &str,
    29→    hostname: &str,
    30→    is_persistent: bool,
    31→) -> Result<Machine, sqlx::Error> {
    32→    sqlx::query_as::<_, Machine>(
    33→        r#"
    34→        INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen)
    35→        VALUES ($1, $2, $3, 'online', NOW())
    36→        ON CONFLICT (agent_id) DO UPDATE SET
    37→            hostname = EXCLUDED.hostname,
    38→            status = 'online',
    39→            last_seen = NOW()
    40→        RETURNING *
    41→        "#,
    42→    )
    43→    .bind(agent_id)
    44→    .bind(hostname)
    45→    .bind(is_persistent)
    46→    .fetch_one(pool)
    47→    .await
    48→}
    49→
    50→/// Update machine status and info
    51→pub async fn update_machine_status(
    52→    pool: &PgPool,
    53→    agent_id: &str,
    54→    status: &str,
    55→    os_version: Option<&str>,
    56→    is_elevated: bool,
    57→    session_id: Option<Uuid>,
    58→) -> Result<(), sqlx::Error> {
    59→    sqlx::query(
    60→        r#"
    61→        UPDATE connect_machines SET
    62→            status = $1,
    63→            os_version = COALESCE($2, os_version),
    64→            is_elevated = $3,
    65→            last_seen = NOW(),
    66→            last_session_id = COALESCE($4, last_session_id)
    67→        WHERE agent_id = $5
    68→        "#,
    69→    )
    70→    .bind(status)
    71→    .bind(os_version)
    72→    .bind(is_elevated)
    73→    .bind(session_id)
    74→    .bind(agent_id)
    75→    .execute(pool)
    76→    .await?;
    77→    Ok(())
    78→}
    79→
    80→/// Get all persistent machines (for restore on startup)
    81→pub async fn get_all_machines(pool: &PgPool) -> Result<Vec<Machine>, sqlx::Error> {
    82→    sqlx::query_as::<_, Machine>(
    83→        "SELECT * FROM connect_machines WHERE is_persistent = true ORDER BY hostname"
    84→    )
    85→    .fetch_all(pool)
    86→    .await
    87→}
    88→
    89→/// Get machine by agent_id
    90→pub async fn get_machine_by_agent_id(
    91→    pool: &PgPool,
    92→    agent_id: &str,
    93→) -> Result<Option<Machine>, sqlx::Error> {
    94→    sqlx::query_as::<_, Machine>(
    95→        "SELECT * FROM connect_machines WHERE agent_id = $1"
    96→    )
    97→    .bind(agent_id)
    98→    .fetch_optional(pool)
    99→    .await
   100→}
   101→
   102→/// Mark machine as offline
   103→pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
   104→    sqlx::query("UPDATE connect_machines SET status = 'offline', last_seen = NOW() WHERE agent_id = $1")
   105→        .bind(agent_id)
   106→        .execute(pool)
   107→        .await?;
   108→    Ok(())
   109→}
   110→
   111→/// Delete a machine record
   112→pub async fn delete_machine(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
   113→    sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
   114→        .bind(agent_id)
   115→        .execute(pool)
   116→        .await?;
   117→    Ok(())
   118→}
   119→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! Machine/Agent database operations

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

/// Machine record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Machine {
    pub id: Uuid,
    pub agent_id: String,
    pub hostname: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub is_persistent: bool,
    pub first_seen: DateTime<Utc>,
    pub last_seen: DateTime<Utc>,
    pub last_session_id: Option<Uuid>,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Get or create a machine by agent_id (upsert)
pub async fn upsert_machine(
    pool: &PgPool,
    agent_id: &str,
    hostname: &str,
    is_persistent: bool,
) -> Result<Machine, sqlx::Error> {
    sqlx::query_as::<_, Machine>(
        r#"
        INSERT INTO connect_machines (agent_id, hostname, is_persistent, status, last_seen)
        VALUES ($1, $2, $3, 'online', NOW())
        ON CONFLICT (agent_id) DO UPDATE SET
            hostname = EXCLUDED.hostname,
            status = 'online',
            last_seen = NOW()
        RETURNING *
        "#,
    )
    .bind(agent_id)
    .bind(hostname)
    .bind(is_persistent)
    .fetch_one(pool)
    .await
}

/// Update machine status and info
pub async fn update_machine_status(
    pool: &PgPool,
    agent_id: &str,
    status: &str,
    os_version: Option<&str>,
    is_elevated: bool,
    session_id: Option<Uuid>,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        UPDATE connect_machines SET
            status = $1,
            os_version = COALESCE($2, os_version),
            is_elevated = $3,
            last_seen = NOW(),
            last_session_id = COALESCE($4, last_session_id)
        WHERE agent_id = $5
        "#,
    )
    .bind(status)
    .bind(os_version)
    .bind(is_elevated)
    .bind(session_id)
    .bind(agent_id)
    .execute(pool)
    .await?;
    Ok(())
}

/// Get all persistent machines (for restore on startup)
pub async fn get_all_machines(pool: &PgPool) -> Result<Vec<Machine>, sqlx::Error> {
    sqlx::query_as::<_, Machine>(
        "SELECT * FROM connect_machines WHERE is_persistent = true ORDER BY hostname"
    )
    .fetch_all(pool)
    .await
}

/// Get machine by agent_id
pub async fn get_machine_by_agent_id(
    pool: &PgPool,
    agent_id: &str,
) -> Result<Option<Machine>, sqlx::Error> {
    sqlx::query_as::<_, Machine>(
        "SELECT * FROM connect_machines WHERE agent_id = $1"
    )
    .bind(agent_id)
    .fetch_optional(pool)
    .await
}

/// Mark machine as offline
pub async fn mark_machine_offline(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
    sqlx::query("UPDATE connect_machines SET status = 'offline', last_seen = NOW() WHERE agent_id = $1")
        .bind(agent_id)
        .execute(pool)
        .await?;
    Ok(())
}

/// Delete a machine record
pub async fn delete_machine(pool: &PgPool, agent_id: &str) -> Result<(), sqlx::Error> {
    sqlx::query("DELETE FROM connect_machines WHERE agent_id = $1")
        .bind(agent_id)
        .execute(pool)
        .await?;
    Ok(())
}


Now I understand the pattern. Let me create the releases.rs module following the same patterns. I also need to add functions to update machine version info.


Now I'll create the releases database module:


Write

//! Release management database operations

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

/// Release record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Release {
    pub id: Uuid,
    pub version: String,
    pub download_url: String,
    pub checksum_sha256: String,
    pub release_notes: Option<String>,
    pub is_stable: bool,
    pub is_mandatory: bool,
    pub min_version: Option<String>,
    pub created_at: DateTime<Utc>,
}

/// Create a new release
pub async fn create_release(
    pool: &PgPool,
    version: &str,
    download_url: &str,
    checksum_sha256: &str,
    release_notes: Option<&str>,
    is_stable: bool,
    is_mandatory: bool,
    min_version: Option<&str>,
) -> Result<Release, sqlx::Error> {
    sqlx::query_as::<_, Release>(
        r#"
        INSERT INTO releases (version, download_url, checksum_sha256, release_notes, is_stable, is_mandatory, min_version)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
        RETURNING *
        "#,
    )
    .bind(version)
    .bind(download_url)
    .bind(checksum_sha256)
    .bind(release_notes)
    .bind(is_stable)
    .bind(is_mandatory)
    .bind(min_version)
    .fetch_one(pool)
    .await
}

/// Get the latest stable release
pub async fn get_latest_stable_release(pool: &PgPool) -> Result<Option<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>(
        r#"
        SELECT * FROM releases
        WHERE is_stable = true
        ORDER BY created_at DESC
        LIMIT 1
        "#,
    )
    .fetch_optional(pool)
    .await
}

/// Get a release by version
pub async fn get_release_by_version(
    pool: &PgPool,
    version: &str,
) -> Result<Option<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>("SELECT * FROM releases WHERE version = $1")
        .bind(version)
        .fetch_optional(pool)
        .await
}

/// Get all releases (ordered by creation date, newest first)
pub async fn get_all_releases(pool: &PgPool) -> Result<Vec<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>("SELECT * FROM releases ORDER BY created_at DESC")
        .fetch_all(pool)
        .await
}

/// Update a release
pub async fn update_release(
    pool: &PgPool,
    version: &str,
    release_notes: Option<&str>,
    is_stable: bool,
    is_mandatory: bool,
) -> Result<Option<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>(
        r#"
        UPDATE releases SET
            release_notes = COALESCE($2, release_notes),
            is_stable = $3,
            is_mandatory = $4
        WHERE version = $1
        RETURNING *
        "#,
    )
    .bind(version)
    .bind(release_notes)
    .bind(is_stable)
    .bind(is_mandatory)
    .fetch_optional(pool)
    .await
}

/// Delete a release
pub async fn delete_release(pool: &PgPool, version: &str) -> Result<bool, sqlx::Error> {
    let result = sqlx::query("DELETE FROM releases WHERE version = $1")
        .bind(version)
        .execute(pool)
        .await?;
    Ok(result.rows_affected() > 0)
}

/// Update machine version info
pub async fn update_machine_version(
    pool: &PgPool,
    agent_id: &str,
    agent_version: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        UPDATE connect_machines SET
            agent_version = $1,
            last_update_check = NOW()
        WHERE agent_id = $2
        "#,
    )
    .bind(agent_version)
    .bind(agent_id)
    .execute(pool)
    .await?;
    Ok(())
}

/// Update machine update status
pub async fn update_machine_update_status(
    pool: &PgPool,
    agent_id: &str,
    update_status: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        UPDATE connect_machines SET
            update_status = $1
        WHERE agent_id = $2
        "#,
    )
    .bind(update_status)
    .bind(agent_id)
    .execute(pool)
    .await?;
    Ok(())
}

/// Get machines that need updates (version < latest stable)
pub async fn get_machines_needing_update(
    pool: &PgPool,
    latest_version: &str,
) -> Result<Vec<String>, sqlx::Error> {
    // Note: This does simple string comparison which works for semver if formatted consistently
    // For production, you might want a more robust version comparison
    let rows: Vec<(String,)> = sqlx::query_as(
        r#"
        SELECT agent_id FROM connect_machines
        WHERE status = 'online'
          AND is_persistent = true
          AND (agent_version IS NULL OR agent_version < $1)
        "#,
    )
    .bind(latest_version)
    .fetch_all(pool)
    .await?;

    Ok(rows.into_iter().map(|(id,)| id).collect())
}


File created successfully at: C:\Users\MikeSwanson\Claude\guru-connect\server\src\db\releases.rs

//! Release management database operations

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

/// Release record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Release {
    pub id: Uuid,
    pub version: String,
    pub download_url: String,
    pub checksum_sha256: String,
    pub release_notes: Option<String>,
    pub is_stable: bool,
    pub is_mandatory: bool,
    pub min_version: Option<String>,
    pub created_at: DateTime<Utc>,
}

/// Create a new release
pub async fn create_release(
    pool: &PgPool,
    version: &str,
    download_url: &str,
    checksum_sha256: &str,
    release_notes: Option<&str>,
    is_stable: bool,
    is_mandatory: bool,
    min_version: Option<&str>,
) -> Result<Release, sqlx::Error> {
    sqlx::query_as::<_, Release>(
        r#"
        INSERT INTO releases (version, download_url, checksum_sha256, release_notes, is_stable, is_mandatory, min_version)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
        RETURNING *
        "#,
    )
    .bind(version)
    .bind(download_url)
    .bind(checksum_sha256)
    .bind(release_notes)
    .bind(is_stable)
    .bind(is_mandatory)
    .bind(min_version)
    .fetch_one(pool)
    .await
}

/// Get the latest stable release
pub async fn get_latest_stable_release(pool: &PgPool) -> Result<Option<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>(
        r#"
        SELECT * FROM releases
        WHERE is_stable = true
        ORDER BY created_at DESC
        LIMIT 1
        "#,
    )
    .fetch_optional(pool)
    .await
}

/// Get a release by version
pub async fn get_release_by_version(
    pool: &PgPool,
    version: &str,
) -> Result<Option<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>("SELECT * FROM releases WHERE version = $1")
        .bind(version)
        .fetch_optional(pool)
        .await
}

/// Get all releases (ordered by creation date, newest first)
pub async fn get_all_releases(pool: &PgPool) -> Result<Vec<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>("SELECT * FROM releases ORDER BY created_at DESC")
        .fetch_all(pool)
        .await
}

/// Update a release
pub async fn update_release(
    pool: &PgPool,
    version: &str,
    release_notes: Option<&str>,
    is_stable: bool,
    is_mandatory: bool,
) -> Result<Option<Release>, sqlx::Error> {
    sqlx::query_as::<_, Release>(
        r#"
        UPDATE releases SET
            release_notes = COALESCE($2, release_notes),
            is_stable = $3,
            is_mandatory = $4
        WHERE version = $1
        RETURNING *
        "#,
    )
    .bind(version)
    .bind(release_notes)
    .bind(is_stable)
    .bind(is_mandatory)
    .fetch_optional(pool)
    .await
}

/// Delete a release
pub async fn delete_release(pool: &PgPool, version: &str) -> Result<bool, sqlx::Error> {
    let result = sqlx::query("DELETE FROM releases WHERE version = $1")
        .bind(version)
        .execute(pool)
        .await?;
    Ok(result.rows_affected() > 0)
}

/// Update machine version info
pub async fn update_machine_version(
    pool: &PgPool,
    agent_id: &str,
    agent_version: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        UPDATE connect_machines SET
            agent_version = $1,
            last_update_check = NOW()
        WHERE agent_id = $2
        "#,
    )
    .bind(agent_version)
    .bind(agent_id)
    .execute(pool)
    .await?;
    Ok(())
}

/// Update machine update status
pub async fn update_machine_update_status(
    pool: &PgPool,
    agent_id: &str,
    update_status: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query(
        r#"
        UPDATE connect_machines SET
            update_status = $1
        WHERE agent_id = $2
        "#,
    )
    .bind(update_status)
    .bind(agent_id)
    .execute(pool)
    .await?;
    Ok(())
}

/// Get machines that need updates (version < latest stable)
pub async fn get_machines_needing_update(
    pool: &PgPool,
    latest_version: &str,
) -> Result<Vec<String>, sqlx::Error> {
    // Note: This does simple string comparison which works for semver if formatted consistently
    // For production, you might want a more robust version comparison
    let rows: Vec<(String,)> = sqlx::query_as(
        r#"
        SELECT agent_id FROM connect_machines
        WHERE status = 'online'
          AND is_persistent = true
          AND (agent_version IS NULL OR agent_version < $1)
        "#,
    )
    .bind(latest_version)
    .fetch_all(pool)
    .await?;

    Ok(rows.into_iter().map(|(id,)| id).collect())
}


Now I need to add the releases module to the db/mod.rs file.


Now add the releases module to db/mod.rs:


Edit

pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
pub mod users;

pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
pub mod users;
pub mod releases;


The file C:\Users\MikeSwanson\Claude\guru-connect\server\src\db\mod.rs has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
     2→//!
     3→//! Handles persistence for machines, sessions, and audit logging.
     4→//! Optional - server works without database if DATABASE_URL not set.
     5→
     6→pub mod machines;
     7→pub mod sessions;
     8→pub mod events;
     9→pub mod support_codes;
    10→pub mod users;
    11→pub mod releases;
    12→
    13→use anyhow::Result;
    14→use sqlx::postgres::PgPoolOptions;
    15→use sqlx::PgPool;

pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
pub mod users;
pub mod releases;

//! Database module for GuruConnect
//!
//! Handles persistence for machines, sessions, and audit logging.
//! Optional - server works without database if DATABASE_URL not set.

pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
pub mod users;

use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tracing::info;

pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
pub use users::*;

/// Database connection pool wrapper
#[derive(Clone)]
pub struct Database {
    pool: PgPool,
}

impl Database {
    /// Initialize database connection pool
    pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self> {
        info!("Connecting to database...");
        let pool = PgPoolOptions::new()
            .max_connections(max_connections)
            .connect(database_url)
            .await?;

        info!("Database connection established");
        Ok(Self { pool })
    }

    /// Run database migrations
    pub async fn migrate(&self) -> Result<()> {
        info!("Running database migrations...");
        sqlx::migrate!("./migrations").run(&self.pool).await?;
        info!("Migrations complete");
        Ok(())
    }

    /// Get reference to the connection pool
    pub fn pool(&self) -> &PgPool {
        &self.pool
    }
}

pub mod events;
pub mod support_codes;
pub mod users;
+pub mod releases;
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;


I also need to add the re-export for releases.


pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
pub use users::*;

pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
pub use users::*;
pub use releases::*;


The file C:\Users\MikeSwanson\Claude\guru-connect\server\src\db\mod.rs has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
    14→use sqlx::postgres::PgPoolOptions;
    15→use sqlx::PgPool;
    16→use tracing::info;
    17→
    18→pub use machines::*;
    19→pub use sessions::*;
    20→pub use events::*;
    21→pub use support_codes::*;
    22→pub use users::*;
    23→pub use releases::*;
    24→
    25→/// Database connection pool wrapper
    26→#[derive(Clone)]
    27→pub struct Database {

pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
pub use users::*;
pub use releases::*;

//! Database module for GuruConnect
//!
//! Handles persistence for machines, sessions, and audit logging.
//! Optional - server works without database if DATABASE_URL not set.

pub mod machines;
pub mod sessions;
pub mod events;
pub mod support_codes;
pub mod users;
pub mod releases;

use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tracing::info;

pub use machines::*;
pub use sessions::*;
pub use events::*;
pub use support_codes::*;
pub use users::*;

/// Database connection pool wrapper
#[derive(Clone)]
pub struct Database {
    pool: PgPool,
}

impl Database {
    /// Initialize database connection pool
    pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self> {
        info!("Connecting to database...");
        let pool = PgPoolOptions::new()
            .max_connections(max_connections)
            .connect(database_url)
            .await?;

        info!("Database connection established");
        Ok(Self { pool })
    }

    /// Run database migrations
    pub async fn migrate(&self) -> Result<()> {
        info!("Running database migrations...");
        sqlx::migrate!("./migrations").run(&self.pool).await?;
        info!("Migrations complete");
        Ok(())
    }

    /// Get reference to the connection pool
    pub fn pool(&self) -> &PgPool {
        &self.pool
    }
}

pub use events::*;
pub use support_codes::*;
pub use users::*;
+pub use releases::*;
/// Database connection pool wrapper
#[derive(Clone)]


Good. Now let me update the todo list and move on to creating the api/releases.rs file. I need to check the existing API structure first.


TodoWrite

Phase 1: Update proto/guruconnect.proto with UpdateInfo, UpdateStatus, UpdateState

Phase 1: Create database migration for releases table and machine version columns

Phase 2: Create server db/releases.rs - CRUD for releases table

Phase 2: Create server api/releases.rs - Admin endpoints

Phase 2: Add GET /api/version endpoint (unauthenticated)

Phase 2: Add POST /api/machines/{id}/update endpoint

Phase 2: Track agent versions when they connect

Phase 3: Create agent/src/update.rs module

Phase 3: Add periodic update check in session idle loop

Phase 3: Handle ADMIN_UPDATE command in session

Phase 3: Add --post-update flag and cleanup logic

Phase 4: Update dashboard.html with version display and update buttons


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Phase 1: Update proto/guruconnect.proto with UpdateInfo, UpdateStatus, UpdateState

Phase 1: Create database migration for releases table and machine version columns

Phase 2: Create server db/releases.rs - CRUD for releases table

Phase 2: Create server api/releases.rs - Admin endpoints

Phase 2: Add GET /api/version endpoint (unauthenticated)

Phase 2: Add POST /api/machines/{id}/update endpoint

Phase 2: Track agent versions when they connect

Phase 3: Create agent/src/update.rs module

Phase 3: Add periodic update check in session idle loop

Phase 3: Handle ADMIN_UPDATE command in session

Phase 3: Add --post-update flag and cleanup logic

Phase 4: Update dashboard.html with version display and update buttons


Let me look at the existing API structure to understand how to add the releases API.


C:\Users\MikeSwanson\Claude\guru-connect\server\src\api\auth.rs
C:\Users\MikeSwanson\Claude\guru-connect\server\src\api\mod.rs
C:\Users\MikeSwanson\Claude\guru-connect\server\src\api\users.rs


Let me read the api/mod.rs and one of the existing API files to understand the patterns.


     1→//! REST API endpoints
     2→
     3→pub mod auth;
     4→pub mod users;
     5→
     6→use axum::{
     7→    extract::{Path, State, Query},
     8→    Json,
     9→};
    10→use serde::{Deserialize, Serialize};
    11→use uuid::Uuid;
    12→
    13→use crate::session::SessionManager;
    14→use crate::db;
    15→
    16→/// Viewer info returned by API
    17→#[derive(Debug, Serialize)]
    18→pub struct ViewerInfoApi {
    19→    pub id: String,
    20→    pub name: String,
    21→    pub connected_at: String,
    22→}
    23→
    24→impl From<crate::session::ViewerInfo> for ViewerInfoApi {
    25→    fn from(v: crate::session::ViewerInfo) -> Self {
    26→        Self {
    27→            id: v.id,
    28→            name: v.name,
    29→            connected_at: v.connected_at.to_rfc3339(),
    30→        }
    31→    }
    32→}
    33→
    34→/// Session info returned by API
    35→#[derive(Debug, Serialize)]
    36→pub struct SessionInfo {
    37→    pub id: String,
    38→    pub agent_id: String,
    39→    pub agent_name: String,
    40→    pub started_at: String,
    41→    pub viewer_count: usize,
    42→    pub viewers: Vec<ViewerInfoApi>,
    43→    pub is_streaming: bool,
    44→    pub is_online: bool,
    45→    pub is_persistent: bool,
    46→    pub last_heartbeat: String,
    47→    pub os_version: Option<String>,
    48→    pub is_elevated: bool,
    49→    pub uptime_secs: i64,
    50→    pub display_count: i32,
    51→}
    52→
    53→impl From<crate::session::Session> for SessionInfo {
    54→    fn from(s: crate::session::Session) -> Self {
    55→        Self {
    56→            id: s.id.to_string(),
    57→            agent_id: s.agent_id,
    58→            agent_name: s.agent_name,
    59→            started_at: s.started_at.to_rfc3339(),
    60→            viewer_count: s.viewer_count,
    61→            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
    62→            is_streaming: s.is_streaming,
    63→            is_online: s.is_online,
    64→            is_persistent: s.is_persistent,
    65→            last_heartbeat: s.last_heartbeat.to_rfc3339(),
    66→            os_version: s.os_version,
    67→            is_elevated: s.is_elevated,
    68→            uptime_secs: s.uptime_secs,
    69→            display_count: s.display_count,
    70→        }
    71→    }
    72→}
    73→
    74→/// List all active sessions
    75→pub async fn list_sessions(
    76→    State(sessions): State<SessionManager>,
    77→) -> Json<Vec<SessionInfo>> {
    78→    let sessions = sessions.list_sessions().await;
    79→    Json(sessions.into_iter().map(SessionInfo::from).collect())
    80→}
    81→
    82→/// Get a specific session by ID
    83→pub async fn get_session(
    84→    State(sessions): State<SessionManager>,
    85→    Path(id): Path<String>,
    86→) -> Result<Json<SessionInfo>, (axum::http::StatusCode, &'static str)> {
    87→    let session_id = Uuid::parse_str(&id)
    88→        .map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;
    89→
    90→    let session = sessions.get_session(session_id).await
    91→        .ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;
    92→
    93→    Ok(Json(SessionInfo::from(session)))
    94→}
    95→
    96→// ============================================================================
    97→// Machine API Types
    98→// ============================================================================
    99→
   100→/// Machine info returned by API
   101→#[derive(Debug, Serialize)]
   102→pub struct MachineInfo {
   103→    pub id: String,
   104→    pub agent_id: String,
   105→    pub hostname: String,
   106→    pub os_version: Option<String>,
   107→    pub is_elevated: bool,
   108→    pub is_persistent: bool,
   109→    pub first_seen: String,
   110→    pub last_seen: String,
   111→    pub status: String,
   112→}
   113→
   114→impl From<db::machines::Machine> for MachineInfo {
   115→    fn from(m: db::machines::Machine) -> Self {
   116→        Self {
   117→            id: m.id.to_string(),
   118→            agent_id: m.agent_id,
   119→            hostname: m.hostname,
   120→            os_version: m.os_version,
   121→            is_elevated: m.is_elevated,
   122→            is_persistent: m.is_persistent,
   123→            first_seen: m.first_seen.to_rfc3339(),
   124→            last_seen: m.last_seen.to_rfc3339(),
   125→            status: m.status,
   126→        }
   127→    }
   128→}
   129→
   130→/// Session record for history
   131→#[derive(Debug, Serialize)]
   132→pub struct SessionRecord {
   133→    pub id: String,
   134→    pub started_at: String,
   135→    pub ended_at: Option<String>,
   136→    pub duration_secs: Option<i32>,
   137→    pub is_support_session: bool,
   138→    pub support_code: Option<String>,
   139→    pub status: String,
   140→}
   141→
   142→impl From<db::sessions::DbSession> for SessionRecord {
   143→    fn from(s: db::sessions::DbSession) -> Self {
   144→        Self {
   145→            id: s.id.to_string(),
   146→            started_at: s.started_at.to_rfc3339(),
   147→            ended_at: s.ended_at.map(|t| t.to_rfc3339()),
   148→            duration_secs: s.duration_secs,
   149→            is_support_session: s.is_support_session,
   150→            support_code: s.support_code,
   151→            status: s.status,
   152→        }
   153→    }
   154→}
   155→
   156→/// Event record for history
   157→#[derive(Debug, Serialize)]
   158→pub struct EventRecord {
   159→    pub id: i64,
   160→    pub session_id: String,
   161→    pub event_type: String,
   162→    pub timestamp: String,
   163→    pub viewer_id: Option<String>,
   164→    pub viewer_name: Option<String>,
   165→    pub details: Option<serde_json::Value>,
   166→    pub ip_address: Option<String>,
   167→}
   168→
   169→impl From<db::events::SessionEvent> for EventRecord {
   170→    fn from(e: db::events::SessionEvent) -> Self {
   171→        Self {
   172→            id: e.id,
   173→            session_id: e.session_id.to_string(),
   174→            event_type: e.event_type,
   175→            timestamp: e.timestamp.to_rfc3339(),
   176→            viewer_id: e.viewer_id,
   177→            viewer_name: e.viewer_name,
   178→            details: e.details,
   179→            ip_address: e.ip_address,
   180→        }
   181→    }
   182→}
   183→
   184→/// Full machine history (for export)
   185→#[derive(Debug, Serialize)]
   186→pub struct MachineHistory {
   187→    pub machine: MachineInfo,
   188→    pub sessions: Vec<SessionRecord>,
   189→    pub events: Vec<EventRecord>,
   190→    pub exported_at: String,
   191→}
   192→
   193→/// Query parameters for machine deletion
   194→#[derive(Debug, Deserialize)]
   195→pub struct DeleteMachineParams {
   196→    /// If true, send uninstall command to agent (if online)
   197→    #[serde(default)]
   198→    pub uninstall: bool,
   199→    /// If true, include history in response before deletion
   200→    #[serde(default)]
   201→    pub export: bool,
   202→}
   203→
   204→/// Response for machine deletion
   205→#[derive(Debug, Serialize)]
   206→pub struct DeleteMachineResponse {
   207→    pub success: bool,
   208→    pub message: String,
   209→    pub uninstall_sent: bool,
   210→    pub history: Option<MachineHistory>,
   211→}
   212→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! REST API endpoints

pub mod auth;
pub mod users;

use axum::{
    extract::{Path, State, Query},
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::session::SessionManager;
use crate::db;

/// Viewer info returned by API
#[derive(Debug, Serialize)]
pub struct ViewerInfoApi {
    pub id: String,
    pub name: String,
    pub connected_at: String,
}

impl From<crate::session::ViewerInfo> for ViewerInfoApi {
    fn from(v: crate::session::ViewerInfo) -> Self {
        Self {
            id: v.id,
            name: v.name,
            connected_at: v.connected_at.to_rfc3339(),
        }
    }
}

/// Session info returned by API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
    pub id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub started_at: String,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfoApi>,
    pub is_streaming: bool,
    pub is_online: bool,
    pub is_persistent: bool,
    pub last_heartbeat: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
}

impl From<crate::session::Session> for SessionInfo {
    fn from(s: crate::session::Session) -> Self {
        Self {
            id: s.id.to_string(),
            agent_id: s.agent_id,
            agent_name: s.agent_name,
            started_at: s.started_at.to_rfc3339(),
            viewer_count: s.viewer_count,
            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
            is_streaming: s.is_streaming,
            is_online: s.is_online,
            is_persistent: s.is_persistent,
            last_heartbeat: s.last_heartbeat.to_rfc3339(),
            os_version: s.os_version,
            is_elevated: s.is_elevated,
            uptime_secs: s.uptime_secs,
            display_count: s.display_count,
        }
    }
}

/// List all active sessions
pub async fn list_sessions(
    State(sessions): State<SessionManager>,
) -> Json<Vec<SessionInfo>> {
    let sessions = sessions.list_sessions().await;
    Json(sessions.into_iter().map(SessionInfo::from).collect())
}

/// Get a specific session by ID
pub async fn get_session(
    State(sessions): State<SessionManager>,
    Path(id): Path<String>,
) -> Result<Json<SessionInfo>, (axum::http::StatusCode, &'static str)> {
    let session_id = Uuid::parse_str(&id)
        .map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;

    let session = sessions.get_session(session_id).await
        .ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;

    Ok(Json(SessionInfo::from(session)))
}

// ============================================================================
// Machine API Types
// ============================================================================

/// Machine info returned by API
#[derive(Debug, Serialize)]
pub struct MachineInfo {
    pub id: String,
    pub agent_id: String,
    pub hostname: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub is_persistent: bool,
    pub first_seen: String,
    pub last_seen: String,
    pub status: String,
}

impl From<db::machines::Machine> for MachineInfo {
    fn from(m: db::machines::Machine) -> Self {
        Self {
            id: m.id.to_string(),
            agent_id: m.agent_id,
            hostname: m.hostname,
            os_version: m.os_version,
            is_elevated: m.is_elevated,
            is_persistent: m.is_persistent,
            first_seen: m.first_seen.to_rfc3339(),
            last_seen: m.last_seen.to_rfc3339(),
            status: m.status,
        }
    }
}

/// Session record for history
#[derive(Debug, Serialize)]
pub struct SessionRecord {
    pub id: String,
    pub started_at: String,
    pub ended_at: Option<String>,
    pub duration_secs: Option<i32>,
    pub is_support_session: bool,
    pub support_code: Option<String>,
    pub status: String,
}

impl From<db::sessions::DbSession> for SessionRecord {
    fn from(s: db::sessions::DbSession) -> Self {
        Self {
            id: s.id.to_string(),
            started_at: s.started_at.to_rfc3339(),
            ended_at: s.ended_at.map(|t| t.to_rfc3339()),
            duration_secs: s.duration_secs,
            is_support_session: s.is_support_session,
            support_code: s.support_code,
            status: s.status,
        }
    }
}

/// Event record for history
#[derive(Debug, Serialize)]
pub struct EventRecord {
    pub id: i64,
    pub session_id: String,
    pub event_type: String,
    pub timestamp: String,
    pub viewer_id: Option<String>,
    pub viewer_name: Option<String>,
    pub details: Option<serde_json::Value>,
    pub ip_address: Option<String>,
}

impl From<db::events::SessionEvent> for EventRecord {
    fn from(e: db::events::SessionEvent) -> Self {
        Self {
            id: e.id,
            session_id: e.session_id.to_string(),
            event_type: e.event_type,
            timestamp: e.timestamp.to_rfc3339(),
            viewer_id: e.viewer_id,
            viewer_name: e.viewer_name,
            details: e.details,
            ip_address: e.ip_address,
        }
    }
}

/// Full machine history (for export)
#[derive(Debug, Serialize)]
pub struct MachineHistory {
    pub machine: MachineInfo,
    pub sessions: Vec<SessionRecord>,
    pub events: Vec<EventRecord>,
    pub exported_at: String,
}

/// Query parameters for machine deletion
#[derive(Debug, Deserialize)]
pub struct DeleteMachineParams {
    /// If true, send uninstall command to agent (if online)
    #[serde(default)]
    pub uninstall: bool,
    /// If true, include history in response before deletion
    #[serde(default)]
    pub export: bool,
}

/// Response for machine deletion
#[derive(Debug, Serialize)]
pub struct DeleteMachineResponse {
    pub success: bool,
    pub message: String,
    pub uninstall_sent: bool,
    pub history: Option<MachineHistory>,
}