heathrow 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +58 -0
- data/README.md +205 -0
- data/bin/heathrow +42 -0
- data/bin/heathrowd +283 -0
- data/docs/ARCHITECTURE.md +1172 -0
- data/docs/DATABASE_SCHEMA.md +685 -0
- data/docs/DEVELOPMENT_WORKFLOW.md +867 -0
- data/docs/DISCORD_SETUP.md +142 -0
- data/docs/GMAIL_OAUTH_SETUP.md +120 -0
- data/docs/PLUGIN_SYSTEM.md +1370 -0
- data/docs/PROJECT_PLAN.md +1022 -0
- data/docs/README.md +417 -0
- data/docs/REDDIT_SETUP.md +174 -0
- data/docs/REPLY_FORWARD.md +182 -0
- data/docs/WHATSAPP_TELEGRAM_SETUP.md +306 -0
- data/heathrow.gemspec +34 -0
- data/heathrowd.service +21 -0
- data/img/heathrow.svg +95 -0
- data/img/rss_threaded.png +0 -0
- data/img/sources.png +0 -0
- data/lib/heathrow/address_book.rb +42 -0
- data/lib/heathrow/config.rb +332 -0
- data/lib/heathrow/database.rb +731 -0
- data/lib/heathrow/database_new.rb +392 -0
- data/lib/heathrow/event_bus.rb +175 -0
- data/lib/heathrow/logger.rb +122 -0
- data/lib/heathrow/message.rb +176 -0
- data/lib/heathrow/message_composer.rb +399 -0
- data/lib/heathrow/message_organizer.rb +774 -0
- data/lib/heathrow/migrations/001_initial_schema.rb +248 -0
- data/lib/heathrow/notmuch.rb +45 -0
- data/lib/heathrow/oauth2_smtp.rb +254 -0
- data/lib/heathrow/plugin/base.rb +212 -0
- data/lib/heathrow/plugin_manager.rb +141 -0
- data/lib/heathrow/poller.rb +93 -0
- data/lib/heathrow/smtp_sender.rb +204 -0
- data/lib/heathrow/source.rb +39 -0
- data/lib/heathrow/sources/base.rb +74 -0
- data/lib/heathrow/sources/discord.rb +357 -0
- data/lib/heathrow/sources/gmail.rb +294 -0
- data/lib/heathrow/sources/imap.rb +198 -0
- data/lib/heathrow/sources/instagram.rb +307 -0
- data/lib/heathrow/sources/instagram_fetch.py +101 -0
- data/lib/heathrow/sources/instagram_send.py +55 -0
- data/lib/heathrow/sources/instagram_send_marionette.py +104 -0
- data/lib/heathrow/sources/maildir.rb +606 -0
- data/lib/heathrow/sources/messenger.rb +212 -0
- data/lib/heathrow/sources/messenger_fetch.js +297 -0
- data/lib/heathrow/sources/messenger_fetch_marionette.py +138 -0
- data/lib/heathrow/sources/messenger_send.js +32 -0
- data/lib/heathrow/sources/messenger_send.py +100 -0
- data/lib/heathrow/sources/reddit.rb +461 -0
- data/lib/heathrow/sources/rss.rb +299 -0
- data/lib/heathrow/sources/slack.rb +375 -0
- data/lib/heathrow/sources/source_manager.rb +328 -0
- data/lib/heathrow/sources/telegram.rb +498 -0
- data/lib/heathrow/sources/webpage.rb +207 -0
- data/lib/heathrow/sources/weechat.rb +479 -0
- data/lib/heathrow/sources/whatsapp.rb +474 -0
- data/lib/heathrow/ui/application.rb +8098 -0
- data/lib/heathrow/ui/navigation.rb +8 -0
- data/lib/heathrow/ui/panes.rb +8 -0
- data/lib/heathrow/ui/source_wizard.rb +567 -0
- data/lib/heathrow/ui/threaded_view.rb +780 -0
- data/lib/heathrow/ui/views.rb +8 -0
- data/lib/heathrow/version.rb +3 -0
- data/lib/heathrow/wizards/discord_wizard.rb +193 -0
- data/lib/heathrow/wizards/slack_wizard.rb +140 -0
- data/lib/heathrow.rb +55 -0
- metadata +147 -0
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
# Heathrow Architecture
|
|
2
|
+
|
|
3
|
+
**Design Philosophy:** Modularity, isolation, testability, and resilience.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [System Overview](#system-overview)
|
|
10
|
+
2. [Layer Architecture](#layer-architecture)
|
|
11
|
+
3. [Component Isolation](#component-isolation)
|
|
12
|
+
4. [Data Flow](#data-flow)
|
|
13
|
+
5. [Concurrency Model](#concurrency-model)
|
|
14
|
+
6. [Error Handling Strategy](#error-handling-strategy)
|
|
15
|
+
7. [Testing Strategy](#testing-strategy)
|
|
16
|
+
8. [Performance Considerations](#performance-considerations)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## System Overview
|
|
21
|
+
|
|
22
|
+
Heathrow is structured as a layered system where each layer has clear responsibilities and well-defined interfaces. No component can bypass its layer or access components outside its scope directly.
|
|
23
|
+
|
|
24
|
+
### Core Principles
|
|
25
|
+
|
|
26
|
+
1. **Single Responsibility** - Each component does one thing well
|
|
27
|
+
2. **Interface Segregation** - Components depend on abstractions, not implementations
|
|
28
|
+
3. **Dependency Inversion** - High-level modules don't depend on low-level modules
|
|
29
|
+
4. **Open/Closed** - Open for extension (plugins), closed for modification (core)
|
|
30
|
+
5. **Fail-Safe** - Component failure doesn't cascade to other components
|
|
31
|
+
|
|
32
|
+
### High-Level Architecture
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
┌───────────────────────────────────────────────────────────┐
|
|
36
|
+
│ User │
|
|
37
|
+
└─────────────────────────┬─────────────────────────────────┘
|
|
38
|
+
│
|
|
39
|
+
▼
|
|
40
|
+
┌───────────────────────────────────────────────────────────┐
|
|
41
|
+
│ UI Layer │
|
|
42
|
+
│ ┌─────────────────────────────────────────────────────┐ │
|
|
43
|
+
│ │ rcurses (TUI Framework) │ │
|
|
44
|
+
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
|
45
|
+
│ │ │ Panes │ │ Input │ │ Render │ │ │
|
|
46
|
+
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
|
|
47
|
+
│ └─────────────────────────────────────────────────────┘ │
|
|
48
|
+
└─────────────────────────┬─────────────────────────────────┘
|
|
49
|
+
│
|
|
50
|
+
▼
|
|
51
|
+
┌───────────────────────────────────────────────────────────┐
|
|
52
|
+
│ Application Layer │
|
|
53
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
54
|
+
│ │ Message │ │ View │ │ Filter │ │
|
|
55
|
+
│ │ Router │ │ Manager │ │ Engine │ │
|
|
56
|
+
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
|
57
|
+
│ ┌──────────────┐ ┌──────────────┐ │
|
|
58
|
+
│ │ Stream │ │ Search │ │
|
|
59
|
+
│ │ Manager │ │ Engine │ │
|
|
60
|
+
│ └──────────────┘ └──────────────┘ │
|
|
61
|
+
└─────────────────────────┬─────────────────────────────────┘
|
|
62
|
+
│
|
|
63
|
+
▼
|
|
64
|
+
┌───────────────────────────────────────────────────────────┐
|
|
65
|
+
│ Core Layer │
|
|
66
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
67
|
+
│ │ Plugin │ │ Config │ │ Database │ │
|
|
68
|
+
│ │ Manager │ │ Manager │ │ Layer │ │
|
|
69
|
+
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
|
70
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
|
71
|
+
│ │ Event │ │ Logger │ │ Cache │ │
|
|
72
|
+
│ │ Bus │ │ │ │ │ │
|
|
73
|
+
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
|
74
|
+
└─────────────────────────┬─────────────────────────────────┘
|
|
75
|
+
│
|
|
76
|
+
▼
|
|
77
|
+
┌───────────────────────────────────────────────────────────┐
|
|
78
|
+
│ Plugin Layer │
|
|
79
|
+
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
|
80
|
+
│ │ Gmail │ │ Slack │ │Discord │ │ RSS │ │ IRC │ │
|
|
81
|
+
│ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │
|
|
82
|
+
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
|
83
|
+
│ │WhatsApp│ │Telegram│ │ Reddit │ [...] │
|
|
84
|
+
│ └────────┘ └────────┘ └────────┘ │
|
|
85
|
+
└───────────────────────────────────────────────────────────┘
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Layer Architecture
|
|
91
|
+
|
|
92
|
+
### 1. UI Layer
|
|
93
|
+
|
|
94
|
+
**Responsibility:** User interaction and display
|
|
95
|
+
|
|
96
|
+
**Components:**
|
|
97
|
+
- Pane Manager (`lib/heathrow/ui/pane_manager.rb`)
|
|
98
|
+
- Input Handler (`lib/heathrow/ui/input_handler.rb`)
|
|
99
|
+
- Renderer (`lib/heathrow/ui/renderer.rb`)
|
|
100
|
+
- Key Binding Manager (`lib/heathrow/ui/key_bindings.rb`)
|
|
101
|
+
|
|
102
|
+
**Interface:**
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
module Heathrow
|
|
106
|
+
module UI
|
|
107
|
+
class Application
|
|
108
|
+
# Initialize UI with application layer dependencies
|
|
109
|
+
def initialize(message_router, view_manager, event_bus)
|
|
110
|
+
|
|
111
|
+
# Main event loop
|
|
112
|
+
def run
|
|
113
|
+
|
|
114
|
+
# Handle user input
|
|
115
|
+
def handle_input(key)
|
|
116
|
+
|
|
117
|
+
# Refresh display
|
|
118
|
+
def refresh
|
|
119
|
+
|
|
120
|
+
# Shutdown
|
|
121
|
+
def shutdown
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Dependencies:**
|
|
128
|
+
- Down: Application Layer (message_router, view_manager)
|
|
129
|
+
- Up: None (top layer)
|
|
130
|
+
|
|
131
|
+
**Isolation:**
|
|
132
|
+
- UI crashes don't affect data layer
|
|
133
|
+
- Can be replaced with different UI (web, GUI) without changing logic
|
|
134
|
+
- No business logic in UI components
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### 2. Application Layer
|
|
139
|
+
|
|
140
|
+
**Responsibility:** Business logic and workflows
|
|
141
|
+
|
|
142
|
+
**Components:**
|
|
143
|
+
|
|
144
|
+
#### Message Router
|
|
145
|
+
|
|
146
|
+
Routes messages to appropriate views based on filter rules.
|
|
147
|
+
|
|
148
|
+
**File:** `lib/heathrow/message_router.rb`
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
module Heathrow
|
|
152
|
+
class MessageRouter
|
|
153
|
+
def initialize(filter_engine, view_manager, db)
|
|
154
|
+
|
|
155
|
+
# Route a single message
|
|
156
|
+
def route_message(message)
|
|
157
|
+
|
|
158
|
+
# Route multiple messages (batch)
|
|
159
|
+
def route_messages(messages)
|
|
160
|
+
|
|
161
|
+
# Get messages for a specific view
|
|
162
|
+
def messages_for_view(view_id)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### View Manager
|
|
168
|
+
|
|
169
|
+
Manages views (buffers) and their configurations.
|
|
170
|
+
|
|
171
|
+
**File:** `lib/heathrow/view_manager.rb`
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
module Heathrow
|
|
175
|
+
class ViewManager
|
|
176
|
+
def initialize(db, filter_engine)
|
|
177
|
+
|
|
178
|
+
# Get all views
|
|
179
|
+
def all_views
|
|
180
|
+
|
|
181
|
+
# Get view by ID
|
|
182
|
+
def get_view(id)
|
|
183
|
+
|
|
184
|
+
# Get view by key binding
|
|
185
|
+
def get_view_by_key(key)
|
|
186
|
+
|
|
187
|
+
# Create new view
|
|
188
|
+
def create_view(name, filters, key_binding: nil)
|
|
189
|
+
|
|
190
|
+
# Update view
|
|
191
|
+
def update_view(id, attributes)
|
|
192
|
+
|
|
193
|
+
# Delete view
|
|
194
|
+
def delete_view(id)
|
|
195
|
+
|
|
196
|
+
# Get remainder view (catch-all)
|
|
197
|
+
def remainder_view
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### Filter Engine
|
|
203
|
+
|
|
204
|
+
Evaluates filter rules against messages.
|
|
205
|
+
|
|
206
|
+
**File:** `lib/heathrow/filter_engine.rb`
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
module Heathrow
|
|
210
|
+
class FilterEngine
|
|
211
|
+
def initialize
|
|
212
|
+
|
|
213
|
+
# Check if message matches filter
|
|
214
|
+
def matches?(message, filter)
|
|
215
|
+
|
|
216
|
+
# Evaluate complex filter expression
|
|
217
|
+
def evaluate(message, expression)
|
|
218
|
+
|
|
219
|
+
# Parse filter string to AST
|
|
220
|
+
def parse_filter(filter_string)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
#### Stream Manager
|
|
226
|
+
|
|
227
|
+
Manages real-time message streams from plugins.
|
|
228
|
+
|
|
229
|
+
**File:** `lib/heathrow/stream_manager.rb`
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
module Heathrow
|
|
233
|
+
class StreamManager
|
|
234
|
+
def initialize(plugin_manager, event_bus, message_router)
|
|
235
|
+
|
|
236
|
+
# Start streaming from plugin
|
|
237
|
+
def start_stream(source_id)
|
|
238
|
+
|
|
239
|
+
# Stop streaming
|
|
240
|
+
def stop_stream(source_id)
|
|
241
|
+
|
|
242
|
+
# Get active streams
|
|
243
|
+
def active_streams
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
#### Search Engine
|
|
249
|
+
|
|
250
|
+
Full-text search across all messages.
|
|
251
|
+
|
|
252
|
+
**File:** `lib/heathrow/search_engine.rb`
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
module Heathrow
|
|
256
|
+
class SearchEngine
|
|
257
|
+
def initialize(db)
|
|
258
|
+
|
|
259
|
+
# Search messages
|
|
260
|
+
def search(query, filters: {})
|
|
261
|
+
|
|
262
|
+
# Save search
|
|
263
|
+
def save_search(name, query)
|
|
264
|
+
|
|
265
|
+
# Get saved searches
|
|
266
|
+
def saved_searches
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Dependencies:**
|
|
272
|
+
- Down: Core Layer (database, event_bus, plugin_manager)
|
|
273
|
+
- Up: UI Layer
|
|
274
|
+
|
|
275
|
+
**Isolation:**
|
|
276
|
+
- Business logic independent of UI
|
|
277
|
+
- Can run headless (for testing, automation)
|
|
278
|
+
- No direct plugin access (only through plugin_manager)
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
### 3. Core Layer
|
|
283
|
+
|
|
284
|
+
**Responsibility:** Infrastructure services
|
|
285
|
+
|
|
286
|
+
**Components:**
|
|
287
|
+
|
|
288
|
+
#### Plugin Manager
|
|
289
|
+
|
|
290
|
+
**File:** `lib/heathrow/plugin_manager.rb`
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
module Heathrow
|
|
294
|
+
class PluginManager
|
|
295
|
+
def initialize(event_bus, config, db)
|
|
296
|
+
|
|
297
|
+
# Load all enabled plugins
|
|
298
|
+
def load_all
|
|
299
|
+
|
|
300
|
+
# Load specific plugin
|
|
301
|
+
def load_plugin(plugin_type, source_id)
|
|
302
|
+
|
|
303
|
+
# Unload plugin
|
|
304
|
+
def unload_plugin(source_id)
|
|
305
|
+
|
|
306
|
+
# Reload plugin
|
|
307
|
+
def reload_plugin(source_id)
|
|
308
|
+
|
|
309
|
+
# Get plugin instance
|
|
310
|
+
def get_plugin(source_id)
|
|
311
|
+
|
|
312
|
+
# List all loaded plugins
|
|
313
|
+
def list_plugins
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### Config Manager
|
|
319
|
+
|
|
320
|
+
**File:** `lib/heathrow/config.rb`
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
module Heathrow
|
|
324
|
+
class Config
|
|
325
|
+
def initialize(config_path = "~/.heathrow/config.yml")
|
|
326
|
+
|
|
327
|
+
# Get config value (supports dot notation: "ui.theme")
|
|
328
|
+
def get(key_path, default = nil)
|
|
329
|
+
|
|
330
|
+
# Set config value
|
|
331
|
+
def set(key_path, value)
|
|
332
|
+
|
|
333
|
+
# Save to disk
|
|
334
|
+
def save
|
|
335
|
+
|
|
336
|
+
# Reload from disk
|
|
337
|
+
def reload
|
|
338
|
+
|
|
339
|
+
# Validate configuration
|
|
340
|
+
def validate
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
#### Database Layer
|
|
346
|
+
|
|
347
|
+
**File:** `lib/heathrow/database.rb`
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
module Heathrow
|
|
351
|
+
class Database
|
|
352
|
+
def initialize(db_path = "~/.heathrow/heathrow.db")
|
|
353
|
+
|
|
354
|
+
# Execute statement (INSERT, UPDATE, DELETE)
|
|
355
|
+
def exec(sql, params = [])
|
|
356
|
+
|
|
357
|
+
# Query data (SELECT)
|
|
358
|
+
def query(sql, params = [])
|
|
359
|
+
|
|
360
|
+
# Transaction wrapper
|
|
361
|
+
def transaction(&block)
|
|
362
|
+
|
|
363
|
+
# Migrate to latest schema version
|
|
364
|
+
def migrate_to_latest
|
|
365
|
+
|
|
366
|
+
# Backup database
|
|
367
|
+
def backup
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
#### Event Bus
|
|
373
|
+
|
|
374
|
+
**File:** `lib/heathrow/event_bus.rb`
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
module Heathrow
|
|
378
|
+
class EventBus
|
|
379
|
+
def initialize
|
|
380
|
+
|
|
381
|
+
# Subscribe to event type
|
|
382
|
+
# Returns handler_id for unsubscribing
|
|
383
|
+
def subscribe(event_type, &handler)
|
|
384
|
+
|
|
385
|
+
# Unsubscribe handler
|
|
386
|
+
def unsubscribe(handler_id)
|
|
387
|
+
|
|
388
|
+
# Publish event
|
|
389
|
+
def publish(event_type, data)
|
|
390
|
+
|
|
391
|
+
# Clear all handlers
|
|
392
|
+
def clear
|
|
393
|
+
|
|
394
|
+
# Get subscription count
|
|
395
|
+
def subscription_count(event_type = nil)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**Event Types:**
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
# Message events
|
|
404
|
+
:message_received # New message fetched
|
|
405
|
+
:message_sent # Message sent successfully
|
|
406
|
+
:message_read # Message marked as read
|
|
407
|
+
:message_starred # Message starred
|
|
408
|
+
:message_archived # Message archived
|
|
409
|
+
|
|
410
|
+
# Plugin events
|
|
411
|
+
:plugin_loaded # Plugin loaded successfully
|
|
412
|
+
:plugin_unloaded # Plugin unloaded
|
|
413
|
+
:plugin_error # Plugin encountered error
|
|
414
|
+
:plugin_reconnecting # Plugin attempting reconnect
|
|
415
|
+
|
|
416
|
+
# View events
|
|
417
|
+
:view_changed # User switched views
|
|
418
|
+
:view_created # New view created
|
|
419
|
+
:view_deleted # View deleted
|
|
420
|
+
|
|
421
|
+
# UI events
|
|
422
|
+
:ui_refresh # UI needs refresh
|
|
423
|
+
:ui_resize # Terminal resized
|
|
424
|
+
|
|
425
|
+
# Sync events
|
|
426
|
+
:sync_started # Sync began
|
|
427
|
+
:sync_completed # Sync finished
|
|
428
|
+
:sync_failed # Sync error
|
|
429
|
+
|
|
430
|
+
# Search events
|
|
431
|
+
:search_executed # Search performed
|
|
432
|
+
|
|
433
|
+
# Log events
|
|
434
|
+
:log # Log message
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### Logger
|
|
438
|
+
|
|
439
|
+
**File:** `lib/heathrow/logger.rb`
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
module Heathrow
|
|
443
|
+
class Logger
|
|
444
|
+
def initialize(log_path = "~/.heathrow/heathrow.log")
|
|
445
|
+
|
|
446
|
+
# Log methods
|
|
447
|
+
def debug(component, message)
|
|
448
|
+
def info(component, message)
|
|
449
|
+
def warn(component, message)
|
|
450
|
+
def error(component, message, exception = nil)
|
|
451
|
+
|
|
452
|
+
# Rotate log files
|
|
453
|
+
def rotate
|
|
454
|
+
|
|
455
|
+
# Set log level
|
|
456
|
+
def level=(level) # :debug, :info, :warn, :error
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
#### Cache
|
|
462
|
+
|
|
463
|
+
**File:** `lib/heathrow/cache.rb`
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
module Heathrow
|
|
467
|
+
class Cache
|
|
468
|
+
def initialize(ttl: 300)
|
|
469
|
+
|
|
470
|
+
# Get cached value
|
|
471
|
+
def get(key)
|
|
472
|
+
|
|
473
|
+
# Set cached value
|
|
474
|
+
def set(key, value, ttl: nil)
|
|
475
|
+
|
|
476
|
+
# Delete cached value
|
|
477
|
+
def delete(key)
|
|
478
|
+
|
|
479
|
+
# Clear all cache
|
|
480
|
+
def clear
|
|
481
|
+
|
|
482
|
+
# Clear expired entries
|
|
483
|
+
def prune
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Dependencies:**
|
|
489
|
+
- Down: None (bottom layer - only system libraries)
|
|
490
|
+
- Up: Application Layer, Plugin Layer
|
|
491
|
+
|
|
492
|
+
**Isolation:**
|
|
493
|
+
- Infrastructure services used by all layers
|
|
494
|
+
- No business logic
|
|
495
|
+
- Highly tested and stable
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
### 4. Plugin Layer
|
|
500
|
+
|
|
501
|
+
**Responsibility:** External service integrations
|
|
502
|
+
|
|
503
|
+
**Components:**
|
|
504
|
+
- Individual plugins (Gmail, Slack, Discord, etc.)
|
|
505
|
+
- Plugin Base Class
|
|
506
|
+
|
|
507
|
+
**File Structure:**
|
|
508
|
+
|
|
509
|
+
```
|
|
510
|
+
lib/heathrow/plugins/
|
|
511
|
+
├── gmail.rb
|
|
512
|
+
├── slack.rb
|
|
513
|
+
├── discord.rb
|
|
514
|
+
├── telegram.rb
|
|
515
|
+
├── rss.rb
|
|
516
|
+
├── reddit.rb
|
|
517
|
+
├── irc.rb
|
|
518
|
+
└── ...
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
**Dependencies:**
|
|
522
|
+
- Down: None (external APIs only)
|
|
523
|
+
- Up: Core Layer (plugin_manager)
|
|
524
|
+
|
|
525
|
+
**Isolation:**
|
|
526
|
+
- Plugins are completely isolated from each other
|
|
527
|
+
- Plugin crash doesn't affect other plugins or core
|
|
528
|
+
- Plugins loaded/unloaded independently
|
|
529
|
+
- Plugins communicate only via Event Bus
|
|
530
|
+
- No direct plugin-to-plugin calls
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## Component Isolation
|
|
535
|
+
|
|
536
|
+
### Isolation Guarantees
|
|
537
|
+
|
|
538
|
+
| Component | Can Access | Cannot Access | Failure Impact |
|
|
539
|
+
|----------------|--------------------|----------------------|----------------|
|
|
540
|
+
| UI Layer | Application Layer | Core, Plugins | UI only |
|
|
541
|
+
| Message Router | Core Layer | Plugins directly | Routing only |
|
|
542
|
+
| View Manager | DB, Filter Engine | Plugins, UI | Views only |
|
|
543
|
+
| Filter Engine | None | All others | Filtering only |
|
|
544
|
+
| Plugin Manager | DB, Event Bus | Plugins' internals | Plugin mgmt |
|
|
545
|
+
| Plugin (Gmail) | Core API only | Other plugins, UI | Gmail only |
|
|
546
|
+
| Database | SQLite only | All others | ✗ CRITICAL |
|
|
547
|
+
| Event Bus | Logger only | All others | ✗ CRITICAL |
|
|
548
|
+
|
|
549
|
+
### Critical Components
|
|
550
|
+
|
|
551
|
+
These components **must never crash**:
|
|
552
|
+
|
|
553
|
+
1. **Database** - All data access
|
|
554
|
+
2. **Event Bus** - All inter-component communication
|
|
555
|
+
3. **Logger** - Debugging and audit trail
|
|
556
|
+
4. **Config Manager** - System configuration
|
|
557
|
+
|
|
558
|
+
**Protection Strategy:**
|
|
559
|
+
- Extensive unit testing (100% coverage)
|
|
560
|
+
- Integration testing
|
|
561
|
+
- Defensive programming (validate all inputs)
|
|
562
|
+
- Fallback mechanisms
|
|
563
|
+
- No external dependencies (use stdlib only)
|
|
564
|
+
|
|
565
|
+
### Safe Components
|
|
566
|
+
|
|
567
|
+
These components **can crash safely**:
|
|
568
|
+
|
|
569
|
+
1. **Plugins** - Isolated, can be restarted
|
|
570
|
+
2. **Filter Engine** - Defaults to "show all" if fails
|
|
571
|
+
3. **Search Engine** - Non-essential feature
|
|
572
|
+
4. **Cache** - Can be cleared and rebuilt
|
|
573
|
+
|
|
574
|
+
**Recovery Strategy:**
|
|
575
|
+
- Error boundaries catch exceptions
|
|
576
|
+
- Log error details
|
|
577
|
+
- Notify user
|
|
578
|
+
- Continue with degraded functionality
|
|
579
|
+
- Allow retry
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
## Data Flow
|
|
584
|
+
|
|
585
|
+
### Message Ingestion Flow
|
|
586
|
+
|
|
587
|
+
```
|
|
588
|
+
1. Plugin fetches messages from external service
|
|
589
|
+
│
|
|
590
|
+
▼
|
|
591
|
+
2. Plugin.normalize_message() converts to Heathrow::Message
|
|
592
|
+
│
|
|
593
|
+
▼
|
|
594
|
+
3. Plugin emits :message_received event
|
|
595
|
+
│
|
|
596
|
+
▼
|
|
597
|
+
4. StreamManager receives event
|
|
598
|
+
│
|
|
599
|
+
▼
|
|
600
|
+
5. StreamManager passes to MessageRouter
|
|
601
|
+
│
|
|
602
|
+
▼
|
|
603
|
+
6. MessageRouter saves to Database
|
|
604
|
+
│
|
|
605
|
+
▼
|
|
606
|
+
7. MessageRouter evaluates filter rules
|
|
607
|
+
│
|
|
608
|
+
▼
|
|
609
|
+
8. MessageRouter updates view message counts
|
|
610
|
+
│
|
|
611
|
+
▼
|
|
612
|
+
9. MessageRouter emits :ui_refresh event
|
|
613
|
+
│
|
|
614
|
+
▼
|
|
615
|
+
10. UI refreshes display
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
### Message Sending Flow
|
|
619
|
+
|
|
620
|
+
```
|
|
621
|
+
1. User composes message in UI
|
|
622
|
+
│
|
|
623
|
+
▼
|
|
624
|
+
2. UI creates Heathrow::Message object
|
|
625
|
+
│
|
|
626
|
+
▼
|
|
627
|
+
3. UI calls MessageRouter.send_message()
|
|
628
|
+
│
|
|
629
|
+
▼
|
|
630
|
+
4. MessageRouter determines target plugin
|
|
631
|
+
│
|
|
632
|
+
▼
|
|
633
|
+
5. MessageRouter calls Plugin.send_message()
|
|
634
|
+
│
|
|
635
|
+
▼
|
|
636
|
+
6. Plugin sends via external API
|
|
637
|
+
│
|
|
638
|
+
▼
|
|
639
|
+
7. Plugin saves to Database (sent messages)
|
|
640
|
+
│
|
|
641
|
+
▼
|
|
642
|
+
8. Plugin emits :message_sent event
|
|
643
|
+
│
|
|
644
|
+
▼
|
|
645
|
+
9. UI shows confirmation
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### View Switching Flow
|
|
649
|
+
|
|
650
|
+
```
|
|
651
|
+
1. User presses view key (e.g., "1")
|
|
652
|
+
│
|
|
653
|
+
▼
|
|
654
|
+
2. UI calls ViewManager.get_view_by_key("1")
|
|
655
|
+
│
|
|
656
|
+
▼
|
|
657
|
+
3. ViewManager queries Database for view
|
|
658
|
+
│
|
|
659
|
+
▼
|
|
660
|
+
4. ViewManager returns View object
|
|
661
|
+
│
|
|
662
|
+
▼
|
|
663
|
+
5. UI calls MessageRouter.messages_for_view(view.id)
|
|
664
|
+
│
|
|
665
|
+
▼
|
|
666
|
+
6. MessageRouter queries Database with view filters
|
|
667
|
+
│
|
|
668
|
+
▼
|
|
669
|
+
7. MessageRouter returns Message[]
|
|
670
|
+
│
|
|
671
|
+
▼
|
|
672
|
+
8. UI renders messages in pane
|
|
673
|
+
│
|
|
674
|
+
▼
|
|
675
|
+
9. UI emits :view_changed event
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
---
|
|
679
|
+
|
|
680
|
+
## Concurrency Model
|
|
681
|
+
|
|
682
|
+
### Threading Strategy
|
|
683
|
+
|
|
684
|
+
**Main Thread:**
|
|
685
|
+
- UI event loop
|
|
686
|
+
- User input handling
|
|
687
|
+
- Rendering
|
|
688
|
+
|
|
689
|
+
**Background Threads:**
|
|
690
|
+
- Plugin real-time streams (one thread per plugin)
|
|
691
|
+
- Periodic sync tasks
|
|
692
|
+
- Database operations (via connection pool)
|
|
693
|
+
|
|
694
|
+
**Thread Safety:**
|
|
695
|
+
|
|
696
|
+
```ruby
|
|
697
|
+
# Database: Thread-safe via mutex
|
|
698
|
+
class Database
|
|
699
|
+
def initialize(db_path)
|
|
700
|
+
@db = SQLite3::Database.new(db_path)
|
|
701
|
+
@mutex = Mutex.new
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def query(sql, params = [])
|
|
705
|
+
@mutex.synchronize do
|
|
706
|
+
@db.execute(sql, params)
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Event Bus: Thread-safe via Queue
|
|
712
|
+
class EventBus
|
|
713
|
+
def initialize
|
|
714
|
+
@handlers = {}
|
|
715
|
+
@queue = Queue.new
|
|
716
|
+
@mutex = Mutex.new
|
|
717
|
+
|
|
718
|
+
# Start event processing thread
|
|
719
|
+
start_processor
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def publish(event_type, data)
|
|
723
|
+
@queue.push([event_type, data])
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
private
|
|
727
|
+
|
|
728
|
+
def start_processor
|
|
729
|
+
Thread.new do
|
|
730
|
+
loop do
|
|
731
|
+
event_type, data = @queue.pop
|
|
732
|
+
dispatch(event_type, data)
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def dispatch(event_type, data)
|
|
738
|
+
handlers = @mutex.synchronize { @handlers[event_type]&.dup || [] }
|
|
739
|
+
handlers.each { |h| h.call(data) rescue nil }
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Plugin Streams: Isolated threads
|
|
744
|
+
class StreamManager
|
|
745
|
+
def start_stream(source_id)
|
|
746
|
+
plugin = @plugin_manager.get_plugin(source_id)
|
|
747
|
+
|
|
748
|
+
thread = Thread.new do
|
|
749
|
+
loop do
|
|
750
|
+
begin
|
|
751
|
+
messages = plugin.fetch_messages
|
|
752
|
+
messages.each { |m| @message_router.route_message(m) }
|
|
753
|
+
sleep plugin.poll_interval
|
|
754
|
+
rescue => e
|
|
755
|
+
log_error(plugin, e)
|
|
756
|
+
sleep 60 # Backoff on error
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
@streams[source_id] = thread
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
### Synchronization Points
|
|
767
|
+
|
|
768
|
+
**Critical Sections:**
|
|
769
|
+
1. Database writes (protected by mutex)
|
|
770
|
+
2. Event handler registration (protected by mutex)
|
|
771
|
+
3. Plugin list modifications (protected by mutex)
|
|
772
|
+
|
|
773
|
+
**Lock-Free Sections:**
|
|
774
|
+
1. Reading messages from DB (read-only, no locks needed)
|
|
775
|
+
2. Filter evaluation (pure function, no state)
|
|
776
|
+
3. Message rendering (read-only)
|
|
777
|
+
|
|
778
|
+
---
|
|
779
|
+
|
|
780
|
+
## Error Handling Strategy
|
|
781
|
+
|
|
782
|
+
### Error Hierarchy
|
|
783
|
+
|
|
784
|
+
```
|
|
785
|
+
StandardError
|
|
786
|
+
│
|
|
787
|
+
├─ Heathrow::Error (base for all Heathrow errors)
|
|
788
|
+
│ │
|
|
789
|
+
│ ├─ Heathrow::ConfigError
|
|
790
|
+
│ │ ├─ InvalidConfigError
|
|
791
|
+
│ │ └─ MissingConfigError
|
|
792
|
+
│ │
|
|
793
|
+
│ ├─ Heathrow::DatabaseError
|
|
794
|
+
│ │ ├─ MigrationError
|
|
795
|
+
│ │ └─ QueryError
|
|
796
|
+
│ │
|
|
797
|
+
│ ├─ Heathrow::Plugin::Error
|
|
798
|
+
│ │ ├─ ConnectionError
|
|
799
|
+
│ │ ├─ AuthenticationError
|
|
800
|
+
│ │ ├─ RateLimitError
|
|
801
|
+
│ │ ├─ NotFoundError
|
|
802
|
+
│ │ └─ ValidationError
|
|
803
|
+
│ │
|
|
804
|
+
│ └─ Heathrow::UIError
|
|
805
|
+
│ ├─ RenderError
|
|
806
|
+
│ └─ InputError
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### Error Boundaries
|
|
810
|
+
|
|
811
|
+
**UI Layer:**
|
|
812
|
+
|
|
813
|
+
```ruby
|
|
814
|
+
def run
|
|
815
|
+
loop do
|
|
816
|
+
begin
|
|
817
|
+
key = get_input
|
|
818
|
+
handle_input(key)
|
|
819
|
+
refresh
|
|
820
|
+
rescue UIError => e
|
|
821
|
+
show_error_message(e.message)
|
|
822
|
+
log(:error, "UI error: #{e.message}")
|
|
823
|
+
# Continue running
|
|
824
|
+
rescue => e
|
|
825
|
+
log(:error, "Unexpected error: #{e.message}")
|
|
826
|
+
show_crash_screen(e)
|
|
827
|
+
# Attempt to continue or graceful shutdown
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
**Plugin Layer:**
|
|
834
|
+
|
|
835
|
+
```ruby
|
|
836
|
+
def fetch_messages_safe(plugin)
|
|
837
|
+
plugin.fetch_messages
|
|
838
|
+
rescue Plugin::RateLimitError => e
|
|
839
|
+
log(:warn, "Rate limited: #{plugin.name}")
|
|
840
|
+
schedule_retry(plugin, delay: 60)
|
|
841
|
+
[]
|
|
842
|
+
rescue Plugin::ConnectionError => e
|
|
843
|
+
log(:warn, "Connection error: #{plugin.name}")
|
|
844
|
+
schedule_retry(plugin, delay: 30)
|
|
845
|
+
[]
|
|
846
|
+
rescue Plugin::AuthenticationError => e
|
|
847
|
+
log(:error, "Auth failed: #{plugin.name}")
|
|
848
|
+
notify_user("Please re-authenticate #{plugin.name}")
|
|
849
|
+
disable_plugin(plugin)
|
|
850
|
+
[]
|
|
851
|
+
rescue => e
|
|
852
|
+
log(:error, "Plugin crashed: #{plugin.name} - #{e.message}")
|
|
853
|
+
disable_plugin(plugin)
|
|
854
|
+
[]
|
|
855
|
+
end
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
**Database Layer:**
|
|
859
|
+
|
|
860
|
+
```ruby
|
|
861
|
+
def query(sql, params = [])
|
|
862
|
+
@mutex.synchronize do
|
|
863
|
+
@db.execute(sql, params)
|
|
864
|
+
end
|
|
865
|
+
rescue SQLite3::SQLException => e
|
|
866
|
+
log(:error, "SQL error: #{e.message}")
|
|
867
|
+
raise DatabaseError, "Query failed: #{e.message}"
|
|
868
|
+
rescue => e
|
|
869
|
+
log(:error, "Unexpected DB error: #{e.message}")
|
|
870
|
+
# Attempt to reconnect
|
|
871
|
+
reconnect
|
|
872
|
+
retry
|
|
873
|
+
end
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### Recovery Strategies
|
|
877
|
+
|
|
878
|
+
| Error Type | Strategy | User Impact |
|
|
879
|
+
|----------------------|-----------------------------------|-------------|
|
|
880
|
+
| Plugin crash | Disable plugin, notify user | Loss of one source |
|
|
881
|
+
| Database locked | Retry with backoff | Brief delay |
|
|
882
|
+
| Config invalid | Use defaults, notify user | Degraded UX |
|
|
883
|
+
| UI render error | Clear screen, re-render | Brief flicker |
|
|
884
|
+
| Network timeout | Retry, then skip | Delayed sync |
|
|
885
|
+
| Rate limit | Backoff, retry later | Delayed sync |
|
|
886
|
+
| Auth expired | Prompt re-auth | User action required |
|
|
887
|
+
| Out of memory | Clear cache, log warning | Slower performance |
|
|
888
|
+
| Disk full | Notify user, stop syncing | No new messages |
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
892
|
+
## Testing Strategy
|
|
893
|
+
|
|
894
|
+
### Test Pyramid
|
|
895
|
+
|
|
896
|
+
```
|
|
897
|
+
┌─────────────┐
|
|
898
|
+
│ Manual │ ← 5% (exploratory testing)
|
|
899
|
+
│ Testing │
|
|
900
|
+
┌─┴─────────────┴─┐
|
|
901
|
+
│ Integration │ ← 20% (component interaction)
|
|
902
|
+
│ Tests │
|
|
903
|
+
┌─┴─────────────────┴─┐
|
|
904
|
+
│ Unit Tests │ ← 75% (component isolation)
|
|
905
|
+
└─────────────────────┘
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
### Unit Testing
|
|
909
|
+
|
|
910
|
+
**Goal:** Test individual components in isolation
|
|
911
|
+
|
|
912
|
+
**Coverage Target:** 90%+ for critical components, 70%+ overall
|
|
913
|
+
|
|
914
|
+
**Example:**
|
|
915
|
+
|
|
916
|
+
```ruby
|
|
917
|
+
# test/test_filter_engine.rb
|
|
918
|
+
class TestFilterEngine < Minitest::Test
|
|
919
|
+
def setup
|
|
920
|
+
@engine = Heathrow::FilterEngine.new
|
|
921
|
+
@message = Heathrow::Message.new(
|
|
922
|
+
sender: "test@example.com",
|
|
923
|
+
subject: "Test message",
|
|
924
|
+
content: "Hello world",
|
|
925
|
+
read: false
|
|
926
|
+
)
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def test_simple_equality_filter
|
|
930
|
+
filter = {field: "sender", op: "=", value: "test@example.com"}
|
|
931
|
+
assert @engine.matches?(@message, filter)
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
def test_complex_and_filter
|
|
935
|
+
filter = {
|
|
936
|
+
rules: [
|
|
937
|
+
{field: "read", op: "=", value: false},
|
|
938
|
+
{field: "sender", op: "=", value: "test@example.com"}
|
|
939
|
+
],
|
|
940
|
+
logic: "AND"
|
|
941
|
+
}
|
|
942
|
+
assert @engine.matches?(@message, filter)
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
### Integration Testing
|
|
948
|
+
|
|
949
|
+
**Goal:** Test component interaction
|
|
950
|
+
|
|
951
|
+
**Coverage:** Key workflows and data flows
|
|
952
|
+
|
|
953
|
+
**Example:**
|
|
954
|
+
|
|
955
|
+
```ruby
|
|
956
|
+
# test/integration/test_message_flow.rb
|
|
957
|
+
class TestMessageFlow < Minitest::Test
|
|
958
|
+
def setup
|
|
959
|
+
@db = Database.new(":memory:")
|
|
960
|
+
@event_bus = EventBus.new
|
|
961
|
+
@config = Config.new
|
|
962
|
+
@plugin_manager = PluginManager.new(@event_bus, @config, @db)
|
|
963
|
+
@message_router = MessageRouter.new(@filter_engine, @view_manager, @db)
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def test_message_ingestion_to_view
|
|
967
|
+
# 1. Create test plugin
|
|
968
|
+
source_id = create_test_source("test_plugin")
|
|
969
|
+
plugin = @plugin_manager.load_plugin("test_plugin", source_id)
|
|
970
|
+
|
|
971
|
+
# 2. Create test view
|
|
972
|
+
view = @view_manager.create_view("Test", {field: "sender", op: "=", value: "test@example.com"})
|
|
973
|
+
|
|
974
|
+
# 3. Fetch messages
|
|
975
|
+
messages = plugin.fetch_messages
|
|
976
|
+
|
|
977
|
+
# 4. Route to views
|
|
978
|
+
messages.each { |m| @message_router.route_message(m) }
|
|
979
|
+
|
|
980
|
+
# 5. Verify messages in view
|
|
981
|
+
view_messages = @message_router.messages_for_view(view.id)
|
|
982
|
+
assert view_messages.count > 0
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
### End-to-End Testing
|
|
988
|
+
|
|
989
|
+
**Goal:** Test complete user workflows
|
|
990
|
+
|
|
991
|
+
**Coverage:** Critical paths only
|
|
992
|
+
|
|
993
|
+
**Example:**
|
|
994
|
+
|
|
995
|
+
```ruby
|
|
996
|
+
# test/e2e/test_user_workflows.rb
|
|
997
|
+
class TestUserWorkflows < Minitest::Test
|
|
998
|
+
def test_read_and_reply_workflow
|
|
999
|
+
# 1. Start application
|
|
1000
|
+
app = Heathrow::UI::Application.new
|
|
1001
|
+
|
|
1002
|
+
# 2. Simulate view switch
|
|
1003
|
+
app.handle_input("1") # Switch to view 1
|
|
1004
|
+
|
|
1005
|
+
# 3. Simulate message selection
|
|
1006
|
+
app.handle_input("j") # Move down
|
|
1007
|
+
app.handle_input("ENTER") # Open message
|
|
1008
|
+
|
|
1009
|
+
# 4. Verify message marked as read
|
|
1010
|
+
message = app.current_message
|
|
1011
|
+
assert message.read
|
|
1012
|
+
|
|
1013
|
+
# 5. Simulate reply
|
|
1014
|
+
app.handle_input("r") # Reply
|
|
1015
|
+
# ... compose and send
|
|
1016
|
+
|
|
1017
|
+
# 6. Verify reply sent
|
|
1018
|
+
assert_event_published(:message_sent)
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
### Test Helpers
|
|
1024
|
+
|
|
1025
|
+
```ruby
|
|
1026
|
+
# test/test_helper.rb
|
|
1027
|
+
module TestHelper
|
|
1028
|
+
def create_test_message(overrides = {})
|
|
1029
|
+
defaults = {
|
|
1030
|
+
external_id: SecureRandom.uuid,
|
|
1031
|
+
sender: "test@example.com",
|
|
1032
|
+
subject: "Test",
|
|
1033
|
+
content: "Test message",
|
|
1034
|
+
timestamp: Time.now.to_i,
|
|
1035
|
+
received_at: Time.now.to_i
|
|
1036
|
+
}
|
|
1037
|
+
Heathrow::Message.new(defaults.merge(overrides))
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
def create_test_source(plugin_type)
|
|
1041
|
+
@db.exec(
|
|
1042
|
+
"INSERT INTO sources (name, plugin_type, config, capabilities, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
1043
|
+
["Test Source", plugin_type, "{}", '["read"]', 1, Time.now.to_i, Time.now.to_i]
|
|
1044
|
+
)
|
|
1045
|
+
@db.query("SELECT last_insert_rowid()").first.first
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
---
|
|
1051
|
+
|
|
1052
|
+
## Performance Considerations
|
|
1053
|
+
|
|
1054
|
+
### Performance Budget
|
|
1055
|
+
|
|
1056
|
+
| Operation | Target | Maximum |
|
|
1057
|
+
|------------------------|------------|-----------|
|
|
1058
|
+
| UI refresh | < 50ms | < 100ms |
|
|
1059
|
+
| View switch | < 100ms | < 200ms |
|
|
1060
|
+
| Message fetch (100) | < 2s | < 5s |
|
|
1061
|
+
| Database query | < 20ms | < 50ms |
|
|
1062
|
+
| Search query | < 100ms | < 500ms |
|
|
1063
|
+
| Send message | < 1s | < 3s |
|
|
1064
|
+
| Memory (idle) | < 50MB | < 100MB |
|
|
1065
|
+
| Memory (active) | < 100MB | < 200MB |
|
|
1066
|
+
|
|
1067
|
+
### Optimization Strategies
|
|
1068
|
+
|
|
1069
|
+
**Database:**
|
|
1070
|
+
- Index frequently queried columns
|
|
1071
|
+
- Use prepared statements
|
|
1072
|
+
- Batch inserts in transactions
|
|
1073
|
+
- Periodic VACUUM
|
|
1074
|
+
- Archive old messages
|
|
1075
|
+
|
|
1076
|
+
**UI:**
|
|
1077
|
+
- Lazy rendering (only visible messages)
|
|
1078
|
+
- Virtual scrolling for long lists
|
|
1079
|
+
- Debounce rapid input
|
|
1080
|
+
- Cache rendered content
|
|
1081
|
+
|
|
1082
|
+
**Plugins:**
|
|
1083
|
+
- Connection pooling
|
|
1084
|
+
- Request batching
|
|
1085
|
+
- Response caching
|
|
1086
|
+
- Rate limit respect
|
|
1087
|
+
|
|
1088
|
+
**Memory:**
|
|
1089
|
+
- Weak references for caches
|
|
1090
|
+
- Periodic cache pruning
|
|
1091
|
+
- Stream processing (don't load all at once)
|
|
1092
|
+
- Profile for leaks
|
|
1093
|
+
|
|
1094
|
+
### Monitoring
|
|
1095
|
+
|
|
1096
|
+
```ruby
|
|
1097
|
+
# lib/heathrow/performance_monitor.rb
|
|
1098
|
+
module Heathrow
|
|
1099
|
+
class PerformanceMonitor
|
|
1100
|
+
def measure(operation)
|
|
1101
|
+
start = Time.now
|
|
1102
|
+
result = yield
|
|
1103
|
+
duration = ((Time.now - start) * 1000).round(2)
|
|
1104
|
+
|
|
1105
|
+
log(:debug, "#{operation}: #{duration}ms")
|
|
1106
|
+
|
|
1107
|
+
if duration > threshold_for(operation)
|
|
1108
|
+
log(:warn, "Slow operation #{operation}: #{duration}ms")
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
result
|
|
1112
|
+
end
|
|
1113
|
+
|
|
1114
|
+
private
|
|
1115
|
+
|
|
1116
|
+
def threshold_for(operation)
|
|
1117
|
+
{
|
|
1118
|
+
"ui_refresh" => 100,
|
|
1119
|
+
"database_query" => 50,
|
|
1120
|
+
"message_fetch" => 5000,
|
|
1121
|
+
"view_switch" => 200
|
|
1122
|
+
}[operation] || 1000
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
1125
|
+
end
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
---
|
|
1129
|
+
|
|
1130
|
+
## Deployment Topology
|
|
1131
|
+
|
|
1132
|
+
### Single-User Desktop
|
|
1133
|
+
|
|
1134
|
+
```
|
|
1135
|
+
┌────────────────────────────────┐
|
|
1136
|
+
│ User's Computer │
|
|
1137
|
+
│ ┌──────────────────────────┐ │
|
|
1138
|
+
│ │ Heathrow Process │ │
|
|
1139
|
+
│ │ ┌────────────────────┐ │ │
|
|
1140
|
+
│ │ │ UI (Terminal) │ │ │
|
|
1141
|
+
│ │ ├────────────────────┤ │ │
|
|
1142
|
+
│ │ │ Application │ │ │
|
|
1143
|
+
│ │ ├────────────────────┤ │ │
|
|
1144
|
+
│ │ │ Core + Plugins │ │ │
|
|
1145
|
+
│ │ └────────────────────┘ │ │
|
|
1146
|
+
│ │ ┌────────────────────┐ │ │
|
|
1147
|
+
│ │ │ SQLite DB │ │ │
|
|
1148
|
+
│ │ │ ~/.heathrow/ │ │ │
|
|
1149
|
+
│ │ └────────────────────┘ │ │
|
|
1150
|
+
│ └──────────────────────────┘ │
|
|
1151
|
+
└────────────────────────────────┘
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
### Client-Server (Future)
|
|
1155
|
+
|
|
1156
|
+
```
|
|
1157
|
+
┌─────────────────┐ ┌──────────────────────┐
|
|
1158
|
+
│ Client │ │ Server │
|
|
1159
|
+
│ ┌───────────┐ │ │ ┌────────────────┐ │
|
|
1160
|
+
│ │ UI Only │ │ <───> │ │ Application │ │
|
|
1161
|
+
│ └───────────┘ │ REST │ │ + Core │ │
|
|
1162
|
+
│ │ │ │ + Plugins │ │
|
|
1163
|
+
│ │ │ └────────────────┘ │
|
|
1164
|
+
│ │ │ ┌────────────────┐ │
|
|
1165
|
+
│ │ │ │ PostgreSQL │ │
|
|
1166
|
+
│ │ │ └────────────────┘ │
|
|
1167
|
+
└─────────────────┘ └──────────────────────┘
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
---
|
|
1171
|
+
|
|
1172
|
+
This architecture ensures Heathrow is modular, testable, and resilient. Each component can be developed, tested, and deployed independently without breaking other parts of the system.
|