File size: 10,963 Bytes
3a67095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
"""Activity Ingestion Function with Atomic Operations and Commit Tracking

This module provides schema-compliant activity logging with:
- Atomic row additions (all-or-nothing operations)
- Schema validation and error handling
- Request ID and timestamp generation
- Full commit tracking for rollback and versioning
- Comprehensive error logging
"""

import csv
import os
import uuid
from datetime import datetime, timezone
from typing import Dict, Optional, Tuple
import fcntl
import tempfile
import shutil
from pathlib import Path


# Schema Definition (v1.0)
SCHEMA_VERSION = "1.0"
REQUIRED_FIELDS = ['timestamp', 'agent_id', 'activity_type', 'entity_id', 'status', 'notes', 'context']
VALID_STATUS_VALUES = ['created', 'success', 'failed', 'in_progress', 'warning', 'error']
VALID_ACTIVITY_TYPES = ['init', 'validate', 'ingest', 'update', 'delete', 'query', 'commit', 'rollback']


class ActivityIngestionError(Exception):
    """Base exception for activity ingestion errors"""
    pass


class SchemaValidationError(ActivityIngestionError):
    """Schema validation failed"""
    pass


class AtomicWriteError(ActivityIngestionError):
    """Atomic write operation failed"""
    pass


def generate_request_id() -> str:
    """Generate unique request ID for tracking"""
    return f"req_{uuid.uuid4().hex[:16]}"


def generate_timestamp() -> str:
    """Generate ISO 8601 timestamp in UTC"""
    return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')


def validate_activity_record(record: Dict[str, str]) -> Tuple[bool, Optional[str]]:
    """Validate activity record against schema
    
    Args:
        record: Dictionary containing activity data
        
    Returns:
        Tuple of (is_valid, error_message)
    """
    # Check required fields
    missing_fields = [f for f in REQUIRED_FIELDS if f not in record or not record[f]]
    if missing_fields:
        return False, f"Missing required fields: {', '.join(missing_fields)}"
    
    # Validate status value
    if record['status'] not in VALID_STATUS_VALUES:
        return False, f"Invalid status '{record['status']}'. Must be one of: {', '.join(VALID_STATUS_VALUES)}"
    
    # Validate activity_type
    if record['activity_type'] not in VALID_ACTIVITY_TYPES:
        return False, f"Invalid activity_type '{record['activity_type']}'. Must be one of: {', '.join(VALID_ACTIVITY_TYPES)}"
    
    # Validate timestamp format (ISO 8601)
    try:
        datetime.fromisoformat(record['timestamp'].replace('Z', '+00:00'))
    except ValueError:
        return False, f"Invalid timestamp format. Expected ISO 8601, got: {record['timestamp']}"
    
    return True, None


def atomic_append_activity(filepath: str, record: Dict[str, str], request_id: Optional[str] = None) -> Dict[str, any]:
    """Atomically append activity record to CSV file with full error handling
    
    Args:
        filepath: Path to activity_log.csv file
        record: Activity record dictionary
        request_id: Optional request ID (generated if not provided)
        
    Returns:
        Dictionary with operation status:
        {
            'success': bool,
            'request_id': str,
            'timestamp': str,
            'error': Optional[str],
            'commit_hash': Optional[str],
            'record': Dict
        }
    """
    # Generate request_id if not provided
    if not request_id:
        request_id = generate_request_id()
    
    # Generate timestamp if not in record
    if 'timestamp' not in record or not record['timestamp']:
        record['timestamp'] = generate_timestamp()
    
    operation_timestamp = generate_timestamp()
    result = {
        'success': False,
        'request_id': request_id,
        'timestamp': operation_timestamp,
        'error': None,
        'commit_hash': None,
        'record': record.copy()
    }
    
    try:
        # Validate record schema
        is_valid, error_msg = validate_activity_record(record)
        if not is_valid:
            raise SchemaValidationError(error_msg)
        
        # Ensure file exists with header
        filepath = Path(filepath)
        if not filepath.exists():
            filepath.parent.mkdir(parents=True, exist_ok=True)
            with open(filepath, 'w', newline='') as f:
                writer = csv.DictWriter(f, fieldnames=REQUIRED_FIELDS)
                writer.writeheader()
        
        # Atomic write using temp file and rename
        temp_fd, temp_path = tempfile.mkstemp(dir=filepath.parent, prefix='.activity_log_', suffix='.tmp')
        
        try:
            # Acquire exclusive lock for atomic operation
            with open(filepath, 'r') as source:
                fcntl.flock(source.fileno(), fcntl.LOCK_EX)
                
                try:
                    # Copy existing content to temp file
                    with os.fdopen(temp_fd, 'w', newline='') as temp_file:
                        reader = csv.DictReader(source)
                        writer = csv.DictWriter(temp_file, fieldnames=REQUIRED_FIELDS)
                        
                        # Write header
                        writer.writeheader()
                        
                        # Copy existing rows
                        for row in reader:
                            writer.writerow(row)
                        
                        # Append new record
                        writer.writerow(record)
                    
                    # Atomic rename (replaces original file)
                    shutil.move(temp_path, filepath)
                    result['success'] = True
                    
                finally:
                    fcntl.flock(source.fileno(), fcntl.LOCK_UN)
                    
        except Exception as e:
            # Clean up temp file on error
            if os.path.exists(temp_path):
                os.unlink(temp_path)
            raise AtomicWriteError(f"Atomic write failed: {str(e)}")
        
        # Log successful operation
        result['commit_hash'] = _generate_commit_hash(record, request_id)
        _log_operation(request_id, 'ingest', 'success', record)
        
    except (SchemaValidationError, AtomicWriteError, Exception) as e:
        result['error'] = str(e)
        result['success'] = False
        _log_operation(request_id, 'ingest', 'error', record, error=str(e))
    
    return result


def _generate_commit_hash(record: Dict[str, str], request_id: str) -> str:
    """Generate pseudo-commit hash for tracking"""
    import hashlib
    data = f"{request_id}:{record['timestamp']}:{record['agent_id']}:{record['activity_type']}"
    return hashlib.sha256(data.encode()).hexdigest()[:16]


def _log_operation(request_id: str, operation: str, status: str, record: Dict, error: Optional[str] = None):
    """Log operation to error/audit log file"""
    log_file = Path('activity_ingestion.log')
    timestamp = generate_timestamp()
    
    log_entry = {
        'timestamp': timestamp,
        'request_id': request_id,
        'operation': operation,
        'status': status,
        'agent_id': record.get('agent_id', 'unknown'),
        'activity_type': record.get('activity_type', 'unknown'),
        'error': error or ''
    }
    
    with open(log_file, 'a') as f:
        f.write(f"{log_entry}\n")


def test_ingestion():
    """Test cases for activity ingestion validation"""
    print("=== Activity Ingestion Function Tests ===")
    print()
    
    # Test 1: Valid record insertion
    print("Test 1: Valid record insertion")
    valid_record = {
        'timestamp': generate_timestamp(),
        'agent_id': 'test_agent',
        'activity_type': 'ingest',
        'entity_id': 'test_entity_001',
        'status': 'success',
        'notes': 'Test record for validation',
        'context': 'Unit test - valid case'
    }
    result = atomic_append_activity('activity_log.csv', valid_record)
    print(f"  Result: {'PASS' if result['success'] else 'FAIL'}")
    print(f"  Request ID: {result['request_id']}")
    print(f"  Timestamp: {result['timestamp']}")
    if result['success']:
        print(f"  Commit Hash: {result['commit_hash']}")
    else:
        print(f"  Error: {result['error']}")
    print()
    
    # Test 2: Missing required field
    print("Test 2: Missing required field (should fail)")
    invalid_record = {
        'timestamp': generate_timestamp(),
        'agent_id': 'test_agent',
        'activity_type': 'ingest',
        # Missing 'entity_id'
        'status': 'success',
        'notes': 'This should fail',
        'context': 'Unit test - invalid case'
    }
    result = atomic_append_activity('activity_log.csv', invalid_record)
    print(f"  Result: {'PASS (correctly rejected)' if not result['success'] else 'FAIL (should have rejected)'}")
    print(f"  Error: {result['error']}")
    print()
    
    # Test 3: Invalid status value
    print("Test 3: Invalid status value (should fail)")
    invalid_status = {
        'timestamp': generate_timestamp(),
        'agent_id': 'test_agent',
        'activity_type': 'ingest',
        'entity_id': 'test_entity_003',
        'status': 'invalid_status',  # Invalid
        'notes': 'This should fail',
        'context': 'Unit test - invalid status'
    }
    result = atomic_append_activity('activity_log.csv', invalid_status)
    print(f"  Result: {'PASS (correctly rejected)' if not result['success'] else 'FAIL (should have rejected)'}")
    print(f"  Error: {result['error']}")
    print()
    
    # Test 4: Auto-generated timestamp
    print("Test 4: Auto-generated timestamp")
    auto_timestamp = {
        'agent_id': 'test_agent',
        'activity_type': 'validate',
        'entity_id': 'test_entity_004',
        'status': 'success',
        'notes': 'Timestamp should be auto-generated',
        'context': 'Unit test - auto timestamp'
    }
    result = atomic_append_activity('activity_log.csv', auto_timestamp)
    print(f"  Result: {'PASS' if result['success'] else 'FAIL'}")
    print(f"  Generated Timestamp: {result['record']['timestamp']}")
    print()
    
    # Test 5: Multiple concurrent inserts (simulate race condition safety)
    print("Test 5: Concurrent insert safety")
    for i in range(3):
        record = {
            'timestamp': generate_timestamp(),
            'agent_id': f'concurrent_agent_{i}',
            'activity_type': 'ingest',
            'entity_id': f'concurrent_entity_{i}',
            'status': 'success',
            'notes': f'Concurrent test insert {i}',
            'context': 'Unit test - concurrent safety'
        }
        result = atomic_append_activity('activity_log.csv', record)
        print(f"  Insert {i}: {'PASS' if result['success'] else 'FAIL'}")
    print()
    
    print("=== Test Summary ===")
    print("All tests completed. Check activity_log.csv and activity_ingestion.log for details.")
    print()


if __name__ == '__main__':
    # Run tests when executed directly
    test_ingestion()