State Management Architecture

OpenWork uses a layered state management architecture with Zustand for runtime state and electron-store for persistence. The design prioritizes simplicity, type safety, and performance through targeted updates and message batching.

State Architecture Overview

graph TB
    subgraph "Renderer State (Zustand)"
        STORE[taskStore<br/>Runtime State]
    end
    
    subgraph "Persistence Layer (electron-store)"
        SECURE[secure-storage<br/>Encrypted API Keys]
        SETTINGS[app-settings<br/>Preferences]
        HISTORY[task-history<br/>Task Records]
    end
    
    subgraph "Data Flow"
        GETTER[Getters<br/>Read state]
        SETTER[Setters<br/>Update state]
        ACTION[Actions<br/>Async operations]
    end
    
    subgraph "IPC Bridge"
        API[accomplish.ts<br/>IPC Wrapper]
    end
    
    STORE --> GETTER
    STORE --> SETTER
    STORE --> ACTION
    ACTION --> API
    API --> SECURE
    API --> SETTINGS
    API --> HISTORY
    
    style STORE fill:#e3f2fd
    style SECURE fill:#fff3e0
    style SETTINGS fill:#f3e5f5
    style HISTORY fill:#e8f5e9

Zustand Store Structure

File: apps/desktop/src/renderer/stores/taskStore.ts

State Schema

classDiagram
    class TaskState {
        +currentTask: Task | null
        +isLoading: boolean
        +error: string | null
        +tasks: Task[]
        +permissionRequest: PermissionRequest | null
        +setupProgress: string | null
        +setupProgressTaskId: string | null
        +setupDownloadStep: number
        +startTask(config)
        +setSetupProgress(taskId, message)
        +sendFollowUp(message)
        +cancelTask()
        +interruptTask()
        +setPermissionRequest(request)
        +respondToPermission(response)
        +addTaskUpdate(event)
        +addTaskUpdateBatch(event)
        +updateTaskStatus(taskId, status)
        +loadTasks()
        +loadTaskById(taskId)
        +deleteTask(taskId)
        +clearHistory()
        +reset()
    }
    
    class Task {
        +id: string
        +prompt: string
        +status: TaskStatus
        +sessionId?: string
        +messages: TaskMessage[]
        +createdAt: string
        +startedAt?: string
        +completedAt?: string
        +result?: TaskResult
    }
    
    class TaskStatus {
        <<enumeration>>
        pending
        queued
        running
        waiting_permission
        completed
        failed
        cancelled
        interrupted
    }
    
    TaskState --> Task
    Task --> TaskStatus

Source: Lines 26-59

State Categories

graph TB
    subgraph "Task State"
        CURRENT[currentTask<br/>Currently viewing]
        LIST[tasks<br/>Sidebar list]
        STATUS[isLoading<br/>Operation state]
        ERR[error<br/>Error message]
    end
    
    subgraph "Permission State"
        PERM[permissionRequest<br/>Pending dialog]
    end
    
    subgraph "Setup State"
        PROG[setupProgress<br/>Download message]
        PROG_ID[setupProgressTaskId<br/>Task ID]
        STEP[setupDownloadStep<br/>1=Chromium, 2=FFMPEG, 3=Shell]
    end
    
    subgraph "Actions"
        TASK_OPS[startTask, sendFollowUp,<br/>cancelTask, interruptTask]
        PERM_OPS[setPermissionRequest,<br/>respondToPermission]
        UPDATE_OPS[addTaskUpdate,<br/>addTaskUpdateBatch,<br/>updateTaskStatus]
        PERSIST[loadTasks, loadTaskById,<br/>deleteTask, clearHistory]
    end
    
    CURRENT --> TASK_OPS
    LIST --> TASK_OPS
    PERM --> PERM_OPS
    PROG --> UPDATE_OPS
    UPDATE_OPS --> TASK_OPS
    PERSIST --> LIST
    
    style CURRENT fill:#e3f2fd
    style PERM fill:#fff3e0
    style PROG fill:#f3e5f5

Persistence Layer

electron-store Instances

graph TB
    subgraph "Store Files"
        SECURE_FILE[secure-storage{-dev}.json<br/>userData/]
        SETTINGS_FILE[app-settings.json<br/>userData/]
        HISTORY_FILE[task-history.json<br/>userData/]
    end
    
    subgraph "Security Wrapper"
        SECURE_WRAP[secureStorage.ts<br/>AES-256-GCM encryption]
    end
    
    subgraph "Direct Access"
        SETTINGS_WRAP[appSettings.ts<br/>Plain JSON]
        HISTORY_WRAP[taskHistory.ts<br/>Debounced writes]
    end
    
    SECURE_FILE --> SECURE_WRAP
    SETTINGS_FILE --> SETTINGS_WRAP
    HISTORY_FILE --> HISTORY_WRAP
    
    SECURE_WRAP -->|getApiKey,<br/>storeApiKey| API_KEYS[API Keys]
    SETTINGS_WRAP -->|get/setDebugMode,<br/>get/setOnboardingComplete| PREFS[Preferences]
    HISTORY_WRAP -->|getTasks,<br/>saveTask,<br/>updateTaskStatus| TASKS[Tasks]
    
    style SECURE_WRAP fill:#fff3e0
    style SETTINGS_WRAP fill:#f3e5f5
    style HISTORY_WRAP fill:#e8f5e9

Secure Storage (API Keys)

File: apps/desktop/src/main/store/secureStorage.ts

sequenceDiagram
    participant Renderer as Renderer
    participant IPC as IPC Handler
    participant Secure as secureStorage
    participant Crypto as Node.js crypto
    participant Store as electron-store
    
    Renderer->>IPC: api-key:set
    IPC->>Secure: storeApiKey('anthropic', key)
    Secure->>Crypto: getDerivedKey()
    Crypto-->>Secure: 256-bit key
    Secure->>Crypto: encryptValue(key)
    Crypto->>Crypto: AES-256-GCM encrypt
    Crypto-->>Secure: iv:authTag:ciphertext
    Secure->>Store: Set 'apiKey:anthropic'
    Store->>Store: Write to disk

Storage Format:

// In secure-storage.json
{
  "values": {
    "apiKey:anthropic": "base64iv:base64tag:base64ciphertext",
    "apiKey:openai": "base64iv:base64tag:base64ciphertext"
  },
  "salt": "base64salt"
}

Source: Lines 148-154

App Settings (Preferences)

File: apps/desktop/src/main/store/appSettings.ts

Schema:

interface AppSettingsSchema {
  debugMode: boolean;
  onboardingComplete: boolean;
  selectedModel: SelectedModel | null;
}

Source: Lines 7-14

Defaults (lines 16-26):

const appSettingsStore = new Store<AppSettingsSchema>({
  name: 'app-settings',
  defaults: {
    debugMode: false,
    onboardingComplete: false,
    selectedModel: {
      provider: 'anthropic',
      model: 'anthropic/claude-opus-4-5',
    },
  },
});

Settings Operations:

Operation Handler Purpose
settings:debug-mode getDebugMode() Check if debug panel enabled
settings:set-debug-mode setDebugMode(bool) Toggle debug mode
settings:app-settings getAppSettings() Get all settings
onboarding:complete getOnboardingComplete() Check onboarding status
onboarding:set-complete setOnboardingComplete(bool) Set onboarding complete
model:get getSelectedModel() Get AI model choice
model:set setSelectedModel(model) Set AI model choice

Task History (Tasks)

File: apps/desktop/src/main/store/taskHistory.ts

Schema:

interface StoredTask {
  id: string;
  prompt: string;
  status: TaskStatus;
  messages: TaskMessage[];
  sessionId?: string;
  createdAt: string;
  startedAt?: string;
  completedAt?: string;
}

interface TaskHistorySchema {
  tasks: StoredTask[];
  maxHistoryItems: number;
}

Source: Lines 7-21

Debounced Writes

Source: Lines 31-51

graph TB
    subgraph "Write Operation"
        MODIFY[Modify tasks array]
        PENDING[pendingTasks = newTasks]
        SCHEDULE[schedulePersist]
        CHECK{timeout exists?}
        WAIT[Wait 250ms]
        FLUSH[flushPendingTasks]
        WRITE[Write to electron-store]
        CLEAR[Clear pending state]
    end
    
    MODIFY --> PENDING
    PENDING --> SCHEDULE
    SCHEDULE --> CHECK
    CHECK -->|Yes| WAIT
    CHECK -->|No| CREATE[Create timeout]
    CREATE --> WAIT
    WAIT --> FLUSH
    FLUSH --> WRITE
    WRITE --> CLEAR
    
    style WAIT fill:#fff3e0
    style WRITE fill:#e8f5e9

Purpose: Reduces disk I/O by batching rapid updates

Implementation (lines 39-51):

function schedulePersist(tasks: StoredTask[]): void {
  pendingTasks = tasks;
  if (persistTimeout) {
    return; // Already scheduled
  }
  persistTimeout = setTimeout(() => {
    if (pendingTasks) {
      taskHistoryStore.set('tasks', pendingTasks);
      pendingTasks = null;
    }
    persistTimeout = null;
  }, PERSIST_DEBOUNCE_MS); // 250ms
}

Flush on Quit

Source: Lines 57-66

export function flushPendingTasks(): void {
  if (persistTimeout) {
    clearTimeout(persistTimeout);
    persistTimeout = null;
  }
  if (pendingTasks) {
    taskHistoryStore.set('tasks', pendingTasks);
    pendingTasks = null;
  }
}

Called from: apps/desktop/src/main/index.ts line 187

app.on('before-quit', () => {
  flushPendingTasks();
  disposeTaskManager();
});

State Update Patterns

Action Categories

graph TB
    subgraph "User Actions"
        START[startTask]
        FOLLOW[sendFollowUp]
        CANCEL[cancelTask]
        INT[interruptTask]
    end
    
    subgraph "System Actions"
        UPDATE[addTaskUpdate]
        BATCH[addTaskUpdateBatch]
        STATUS[updateTaskStatus]
        PROG[setSetupProgress]
    end
    
    subgraph "Persistence Actions"
        LOAD[loadTasks]
        LOAD_ID[loadTaskById]
        DELETE[deleteTask]
        CLEAR[clearHistory]
    end
    
    subgraph "Permission Actions"
        SET_PERM[setPermissionRequest]
        RESP_PERM[respondToPermission]
    end
    
    START --> SYSTEM
    FOLLOW --> SYSTEM
    CANCEL --> SYSTEM
    INT --> SYSTEM
    
    style START fill:#e3f2fd
    style BATCH fill:#fff3e0
    style LOAD fill:#f3e5f5

Task Start Flow

Source: Lines 91-128

sequenceDiagram
    participant UI as Component
    participant Store as taskStore
    participant API as accomplish.ts
    participant IPC as Main Process
    
    UI->>Store: startTask({prompt})
    Store->>Store: set({isLoading: true, error: null})
    Store->>API: logEvent({level: info})
    Store->>API: startTask(config)
    API->>IPC: ipcRenderer.invoke('task:start')
    IPC-->>API: Task object
    API-->>Store: task
    
    Note over Store: Update state
    Store->>Store: currentTask = task
    Store->>Store: tasks = [task, ...oldTasks]
    Store->>Store: isLoading = (status === 'queued')
    
    Store->>API: logEvent({status})
    Store-->>UI: task | null

Key Behaviors:

  1. Optimistic UI: Updates state immediately
  2. Queue Handling: Keeps loading state if queued
  3. List Update: Adds to sidebar immediately
  4. Error Handling: Sets error message on failure

Message Update Flow

Source: Lines 277-353

stateDiagram-v2
    [*] --> Receive: addTaskUpdate(event)
    Receive --> CheckTask: Is current task?
    
    CheckTask -->|No| Skip: No update needed
    CheckTask -->|Yes| CheckType: Check event type
    
    CheckType --> Message: type === 'message'
    CheckType --> Complete: type === 'complete'
    CheckType --> Error: type === 'error'
    
    Message --> Append: Append message
    Append --> [*]
    
    Complete --> MapStatus: Map result status
    MapStatus --> UpdateTask: Update currentTask
    MapStatus --> UpdateList: Update tasks list
    UpdateTask --> [*]
    UpdateList --> [*]
    
    Error --> SetFailed: Set status = 'failed'
    SetFailed --> UpdateCurrent: Update currentTask
    UpdateCurrent --> [*]
    
    Skip --> [*]

Selective Updates (lines 286-299):

// Determine if this event is for the currently viewed task
const isCurrentTask = state.currentTask?.id === event.taskId;

// Handle message events - only if viewing this task
if (event.type === 'message' && event.message && isCurrentTask && state.currentTask) {
  updatedCurrentTask = {
    ...state.currentTask,
    messages: [...state.currentTask.messages, event.message],
  };
}

Benefit: Prevents unnecessary re-renders when viewing other tasks

Batch Update Flow

Source: Lines 355-376

graph LR
    subgraph "Single Message Flow"
        M1[Message 1] --> STATE1[State Update]
        M2[Message 2] --> STATE2[State Update]
        M3[Message 3] --> STATE3[State Update]
        STATE1 --> RENDER1[Re-render]
        STATE2 --> RENDER2[Re-render]
        STATE3 --> RENDER3[Re-render]
    end
    
    subgraph "Batched Flow"
        M4[Message 1] --> BUFFER[Buffer 50ms]
        M5[Message 2] --> BUFFER
        M6[Message 3] --> BUFFER
        BUFFER --> STATE[Single State Update]
        STATE --> RENDER[One Re-render]
    end
    
    style STATE1 fill:#ffebee
    style STATE2 fill:#ffebee
    style STATE3 fill:#ffebee
    style STATE fill:#e8f5e9

Implementation (lines 356-375):

addTaskUpdateBatch: (event: TaskUpdateBatchEvent) => {
  set((state) => {
    if (!state.currentTask || state.currentTask.id !== event.taskId) {
      return state;
    }

    // Add all messages in a single state update
    const updatedTask = {
      ...state.currentTask,
      messages: [...state.currentTask.messages, ...event.messages],
    };

    return { currentTask: updatedTask, isLoading: false };
  });
}

Event Subscriptions

Global Subscriptions

Source: Lines 440-465

graph TB
    subgraph "Module Load"
        INIT[Module initializes]
        CHECK{window.accomplish?}
    end
    
    subgraph "Subscriptions"
        PROG_SUB[onTaskProgress]
        UPDATE_SUB[onTaskUpdate]
    end
    
    subgraph "Handlers"
        PROG_H[Handle progress]
        UPDATE_H[Handle completion]
    end
    
    INIT --> CHECK
    CHECK -->|Yes| PROG_SUB
    CHECK -->|Yes| UPDATE_SUB
    
    PROG_SUB --> PROG_H
    UPDATE_SUB --> UPDATE_H
    
    PROG_H --> CLEAR[Clear progress on complete]
    UPDATE_H --> CLEAR
    
    style INIT fill:#e3f2fd
    style PROG_SUB fill:#fff3e0
    style UPDATE_SUB fill:#f3e5f5

Purpose: Capture events even before components mount

Setup Progress Handler (lines 443-453):

window.accomplish.onTaskProgress((progress: unknown) => {
  const event = progress as SetupProgressEvent;
  if (event.message) {
    // Clear progress if installation completed
    if (event.message.toLowerCase().includes('installed successfully')) {
      useTaskStore.getState().setSetupProgress(null, null);
    } else {
      useTaskStore.getState().setSetupProgress(event.taskId, event.message);
    }
  }
});

Completion Handler (lines 456-464):

window.accomplish.onTaskUpdate((event: unknown) => {
  const updateEvent = event as TaskUpdateEvent;
  if (updateEvent.type === 'complete' || updateEvent.type === 'error') {
    const state = useTaskStore.getState();
    if (state.setupProgressTaskId === updateEvent.taskId) {
      state.setSetupProgress(null, null);
    }
  }
});

Status Management

Task Status States

stateDiagram-v2
    [*] --> Pending: Task created
    Pending --> Queued: At max concurrent tasks
    Pending --> Running: Task starts
    Queued --> Running: Slot available
    Running --> WaitingPermission: Permission needed
    WaitingPermission --> Running: Permission granted
    Running --> Completed: Success
    Running --> Failed: Error
    Running --> Interrupted: User interrupted
    Running --> Cancelled: User cancelled
    Queued --> Cancelled: User cancelled
    Interrupted --> Running: User sends follow-up
    Completed --> [*]
    Failed --> [*]
    Cancelled --> [*]
    Interrupted --> [*]

Status Transitions (source: packages/shared/src/types/task.ts lines 5-13):

Status Description Can Transition To
pending Initial state queued, running
queued Waiting for slot running, cancelled
running Executing completed, failed, interrupted, cancelled
waiting_permission Awaiting user input running, cancelled
completed Success -
failed Error -
cancelled User cancelled -
interrupted Paused (Ctrl+C) running

Status Update Handler

Source: Lines 379-399

updateTaskStatus: (taskId: string, status: TaskStatus) => {
  set((state) => {
    // Update in tasks list
    const updatedTasks = state.tasks.map((task) =>
      task.id === taskId
        ? { ...task, status, updatedAt: new Date().toISOString() }
        : task
    );

    // Update currentTask if it matches
    const updatedCurrentTask =
      state.currentTask?.id === taskId
        ? { ...state.currentTask, status, updatedAt: new Date().toISOString() }
        : state.currentTask;

    return {
      tasks: updatedTasks,
      currentTask: updatedCurrentTask,
    };
  });
}

Permission State

Permission Request Flow

sequenceDiagram
    participant Main as Main Process
    participant Store as taskStore
    participant UI as Permission Dialog
    participant User as User
    
    Main->>Store: setPermissionRequest(request)
    Store->>Store: set({permissionRequest: request})
    Store->>UI: Re-render with dialog
    
    UI->>User: Show permission request
    User->>UI: Select option
    UI->>Store: respondToPermission({decision, options})
    Store->>Main: permission:respond IPC
    Main->>Store: set({permissionRequest: null})

Source: Lines 262-275

setPermissionRequest: (request) => {
  set({ permissionRequest: request });
},

respondToPermission: async (response: PermissionResponse) => {
  const accomplish = getAccomplish();
  await accomplish.respondToPermission(response);
  set({ permissionRequest: null });
}

Performance Optimizations

1. Message Batching

Problem: Rapid message streams cause excessive re-renders

Solution: Batch messages within 50ms window

Benefit: Reduces state updates from N to 1

Source: Lines 355-376

2. Selective Updates

Problem: All messages update state regardless of visibility

Solution: Check if message is for current task

Benefit: Avoids unnecessary re-renders

Source: Lines 286-299

3. Debounced Persistence

Problem: Every message writes to disk

Solution: Batch writes with 250ms debounce

Benefit: Reduces disk I/O significantly

Source: apps/desktop/src/main/store/taskHistory.ts lines 31-51

4. Global Event Capture

Problem: Events lost before component mount

Solution: Subscribe at module load time

Benefit: No events missed

Source: Lines 440-465

State Hydration

Initial Load

sequenceDiagram
    participant App as App Component
    participant Store as taskStore
    participant API as accomplish.ts
    participant IPC as Main Process
    
    App->>App: useEffect(() => {})
    App->>Store: loadTasks()
    Store->>API: listTasks()
    API->>IPC: ipcRenderer.invoke('task:list')
    IPC-->>API: Task[] from history
    API-->>Store: tasks
    Store->>Store: set({tasks})
    Store-->>App: Tasks loaded

Source: Lines 401-405

loadTasks: async () => {
  const accomplish = getAccomplish();
  const tasks = await accomplish.listTasks();
  set({ tasks });
}

Task by ID

Source: Lines 407-411

loadTaskById: async (taskId: string) => {
  const accomplish = getAccomplish();
  const task = await accomplish.getTask(taskId);
  set({ currentTask: task, error: task ? null : 'Task not found' });
}

Error State Management

Error Handling Pattern

Source: Lines 116-127, 209-224

graph TB
    subgraph "Error Flow"
        OP[Async Operation]
        TRY[try-catch]
        SUCCESS[Success path]
        FAIL[Error path]
        SET_ERR[Set error state]
        LOG[Log error]
        CLEAR_ERR[Clear on retry]
    end
    
    OP --> TRY
    TRY --> SUCCESS
    TRY -->|throw| FAIL
    FAIL --> SET_ERR
    SET_ERR --> LOG
    SET_ERR --> UI[Display error]
    UI --> CLEAR_ERR
    CLEAR_ERR --> OP
    
    style FAIL fill:#ffebee
    style SET_ERR fill:#fff3e0

Example (startTask error handling):

} catch (err) {
  set({
    error: err instanceof Error ? err.message : 'Failed to start task',
    isLoading: false,
  });
  void accomplish.logEvent({
    level: 'error',
    message: 'UI task start failed',
    context: { error: err instanceof Error ? err.message : String(err) },
  });
  return null;
}

Summary

OpenWork’s state management provides:

Layer Technology Purpose
Runtime State Zustand Fast, simple state management
API Keys AES-256-GCM encrypted store Secure credential storage
Settings electron-store (plain) User preferences
Task History electron-store (debounced) Persistent task records
IPC Bridge accomplish.ts Type-safe IPC wrapper

Key Design Principles:

  1. Simplicity: Zustand over Redux for minimal boilerplate
  2. Type Safety: Full TypeScript coverage
  3. Performance: Message batching, selective updates
  4. Reliability: Debounced persistence, flush on quit
  5. User Experience: Optimistic UI, error boundaries

Back to top

OpenWork Documentation - Community documentation for accomplish-ai/openwork