Chore: clean up staged changes for release

- Remove standalone pulse-assistant architecture doc (content lives in CLAUDE.md)
- Add CountdownTimer component for patrol schedule display
- Rewrite patrol handler test to focus on interval persistence
- Extract MockStateProvider to shared test file
This commit is contained in:
rcourtman
2026-02-02 23:17:40 +00:00
parent c8483f8116
commit 744eeb0270
5 changed files with 121 additions and 1464 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
import { createSignal, onCleanup, onMount, createEffect } from 'solid-js';
interface CountdownTimerProps {
targetDate: string;
prefix?: string;
className?: string;
}
export function CountdownTimer(props: CountdownTimerProps) {
const [timeLeft, setTimeLeft] = createSignal('');
const calculateTimeLeft = () => {
const now = new Date();
const target = new Date(props.targetDate);
const diffMs = target.getTime() - now.getTime();
if (diffMs <= 0) {
return 'Due now';
}
const diffSecs = Math.floor(diffMs / 1000);
const hours = Math.floor(diffSecs / 3600);
const minutes = Math.floor((diffSecs % 3600) / 60);
const seconds = diffSecs % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
let timer: number | undefined;
const update = () => {
setTimeLeft(calculateTimeLeft());
};
createEffect(() => {
// Reset/update immediately when prop changes
if (props.targetDate) {
update();
}
});
onMount(() => {
update();
timer = setInterval(update, 1000) as unknown as number;
});
onCleanup(() => {
clearInterval(timer);
});
return (
<span class={props.className}>
{props.prefix}{timeLeft()}
</span>
);
}

View File

@@ -7,3 +7,4 @@ export { RunToolCallTrace } from './RunToolCallTrace';
export { PatrolStatusBar } from './PatrolStatusBar';
export { RunHistoryEntry } from './RunHistoryEntry';
export { RunHistoryPanel } from './RunHistoryPanel';
export { CountdownTimer } from './CountdownTimer';

View File

@@ -1,162 +1,70 @@
package api
import (
"context"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/stretchr/testify/require"
)
// MockStateProvider for initializing PatrolService
type MockStateProvider struct{}
func TestAISettingsHandler_PatrolInterval_SpecificCheck(t *testing.T) {
t.Parallel()
func (m *MockStateProvider) GetState() models.StateSnapshot {
return models.StateSnapshot{}
}
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
// MockOrchestrator for testing GetFixedCount integration
type MockOrchestrator struct {
FixedCount int
}
handler := newTestAISettingsHandler(cfg, persistence, nil)
func (m *MockOrchestrator) InvestigateFinding(ctx context.Context, finding *ai.InvestigationFinding, autonomyLevel string) error {
return nil
}
func (m *MockOrchestrator) GetInvestigationByFinding(findingID string) *ai.InvestigationSession {
return nil
}
func (m *MockOrchestrator) GetRunningCount() int {
return 0
}
func (m *MockOrchestrator) GetFixedCount() int {
return m.FixedCount
}
func (m *MockOrchestrator) CanStartInvestigation() bool {
return true
}
func (m *MockOrchestrator) ReinvestigateFinding(ctx context.Context, findingID, autonomyLevel string) error {
return nil
}
func (m *MockOrchestrator) Shutdown(ctx context.Context) error {
return nil
}
// Step 1: Update settings to set patrol interval to 15 minutes explicitly
{
body, _ := json.Marshal(AISettingsUpdateRequest{
PatrolIntervalMinutes: ptr(15),
})
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
func TestHandleGetPatrolStatus_Integration(t *testing.T) {
// Setup temporary persistence
tempDir := t.TempDir()
// persistence := config.NewConfigPersistence(tempDir) // Unused
mtPersistence := config.NewMultiTenantPersistence(tempDir)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String())
}
// Create handler (this will initialize legacyAIService inside)
handler := NewAISettingsHandler(mtPersistence, nil, nil)
// Manually inject legacy persistence since NewAISettingsHandler uses it for default service
// The constructor initializes legacyAIService if defaultPersistence is provided.
// We need to match how the constructor works.
// Since NewAISettingsHandler takes *MultiTenantPersistence, we need to ensure it can get "default" persistence
// OR we can manually set up the legacy service if the constructor behavior is complex.
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
// Better approach: mimic constructor logic by ensuring "default" exists or use a helper?
// Actually NewAISettingsHandler code:
// if mtp != nil { if p, err := mtp.GetPersistence("default"); ... defaultPersistence = p }
// So we need to ensure "default" persistence is available in mtPersistence?
// config.NewMultiTenantPersistence creates the dir but maybe not the sub-persistence until requested?
// Let's rely on the fact that GetAIService("default") calls mtPersistence.GetPersistence("default")
// The API response should return 15 minutes, NOT 360 (6 hours)
if resp.PatrolIntervalMinutes != 15 {
t.Fatalf("expected PatrolIntervalMinutes=15, got %d. Did the migration logic override the user setting?", resp.PatrolIntervalMinutes)
}
// Let's explicitly setup the handler's internal service for the test context
svc := handler.GetAIService(context.Background())
if svc == nil {
// Fallback: manually set it if constructor didn't pick it up (likely due to empty mtp)
// But GetAIService creates it if missing for orgID.
// For "default", it returns legacyAIService.
// Check that the preset is cleared as expected when setting explicit minutes
if resp.PatrolSchedulePreset != "" {
t.Fatalf("expected PatrolSchedulePreset to be empty, got %q", resp.PatrolSchedulePreset)
}
}
// To force a clean service creation we can use a specific tenant ID context
ctx := context.WithValue(context.Background(), "org_id", "test-org")
svc = handler.GetAIService(ctx)
require.NotNil(t, svc, "Service should be created for new tenant")
// Step 2: Verify persistence by fetching settings again
{
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
rec := httptest.NewRecorder()
handler.HandleGetAISettings(rec, req)
// Initialize PatrolService by setting StateProvider
svc.SetStateProvider(&MockStateProvider{})
patrol := svc.GetPatrolService()
require.NotNil(t, patrol, "PatrolService should be initialized")
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
}
// Inject MockOrchestrator with specific FixedCount
mockOrch := &MockOrchestrator{FixedCount: 42}
patrol.SetInvestigationOrchestrator(mockOrch)
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
// Make request
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
// Inject org_id into context so handler uses the same service
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.HandleGetPatrolStatus(w, req)
require.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Verify FixedCount is propagated
// Use float64 because JSON unmarshals numbers as floats
require.Equal(t, 42.0, response["fixed_count"], "fixed_count should match mocked value")
// Verify other fields exist
require.Contains(t, response, "running")
require.Contains(t, response, "healthy")
} else {
// If service was found (default case)
svc.SetStateProvider(&MockStateProvider{})
patrol := svc.GetPatrolService()
require.NotNil(t, patrol)
mockOrch := &MockOrchestrator{FixedCount: 42}
patrol.SetInvestigationOrchestrator(mockOrch)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
w := httptest.NewRecorder()
handler.HandleGetPatrolStatus(w, req)
require.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
require.Equal(t, 42.0, response["fixed_count"])
if resp.PatrolIntervalMinutes != 15 {
t.Fatalf("expected persisted PatrolIntervalMinutes=15, got %d", resp.PatrolIntervalMinutes)
}
}
}
// Helper to handle context key if needed (assuming GetOrgID uses a specific key)
// But since we can't import internal/api's internal helpers easily if they are unexported,
// we rely on standard behavior or just use the default path if possible.
// internal/api/utils.go likely has GetOrgID. Ideally we'd test the default path.
func TestHandleGetPatrolStatus_NotInitialized(t *testing.T) {
// Setup handler with NO state provider -> NO patrol service
tempDir := t.TempDir()
mtPersistence := config.NewMultiTenantPersistence(tempDir)
handler := NewAISettingsHandler(mtPersistence, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
w := httptest.NewRecorder()
handler.HandleGetPatrolStatus(w, req)
require.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Should indicate not enabled/running
require.Equal(t, false, response["running"])
require.Equal(t, false, response["enabled"])
}

View File

@@ -0,0 +1,14 @@
package api
import (
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
// MockStateProvider is a mock implementation of ai.StateProvider for API tests
type MockStateProvider struct {
State models.StateSnapshot
}
func (m *MockStateProvider) GetState() models.StateSnapshot {
return m.State
}