feat: wire BuildPulse to Appwrite-backed persistence

This commit is contained in:
OpenClaw Bot
2026-05-07 00:31:33 +02:00
parent bdf8773797
commit 63c5a23b48
19 changed files with 1427 additions and 93 deletions
+53 -18
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent } from 'react'
import './index.css'
import { createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
import { fetchRemoteState, pushRemoteState } from './store/remote'
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
import type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
@@ -41,7 +42,6 @@ const initialPulseDraft = {
confidence: '0.9',
evidenceRefs: '',
traceId: '',
structuredPayload: '{}',
}
const columnLabels: Record<FeatureColumn, string> = {
@@ -74,11 +74,61 @@ function App() {
const [pulseTypeFilter, setPulseTypeFilter] = useState('all')
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
const hasHydratedRemote = useRef(false)
const initialLocalStateRef = useRef(appState)
useEffect(() => {
saveAppState(appState)
}, [appState])
useEffect(() => {
let cancelled = false
const hydrate = async () => {
try {
const remoteState = await fetchRemoteState()
if (cancelled) return
if (remoteState && validateAppState(remoteState)) {
setAppState(remoteState)
saveAppState(remoteState)
setStatusMessage('Loaded state from Appwrite on the Unraid server.')
} else {
await pushRemoteState(initialLocalStateRef.current)
if (cancelled) return
setStatusMessage('Seeded Appwrite on Unraid from the local BuildPulse state.')
}
setBackendMode('appwrite')
} catch {
if (cancelled) return
setBackendMode('local-cache')
setStatusMessage('Appwrite backend unavailable, so BuildPulse is using the local cache for now.')
} finally {
hasHydratedRemote.current = true
}
}
void hydrate()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!hasHydratedRemote.current || backendMode !== 'appwrite') return
const timer = window.setTimeout(() => {
void pushRemoteState(appState).catch(() => {
setBackendMode('local-cache')
setStatusMessage('Saved locally. Appwrite sync tripped over itself, so the cache is carrying the load.')
})
}, 350)
return () => window.clearTimeout(timer)
}, [appState, backendMode])
const groupedFeatures = useMemo(
() =>
FEATURE_COLUMNS.reduce<Record<FeatureColumn, Feature[]>>((acc, column) => {
@@ -324,7 +374,6 @@ function App() {
agent_id: current.settings.default_agent_id,
pulse_type: 'PARKED_IDEA',
message: `Converted parked idea “${item.title}” into a Next feature.`,
structured_payload: {},
confidence_score: 0.8,
evidence_refs: ['Converted from Parking Lot'],
},
@@ -346,7 +395,6 @@ function App() {
confidence: String(pulse.confidence_score),
evidenceRefs: arrayToLines(pulse.evidence_refs),
traceId: pulse.trace_id ?? '',
structuredPayload: JSON.stringify(pulse.structured_payload ?? {}, null, 2),
})
}
@@ -361,14 +409,6 @@ function App() {
return
}
let parsedPayload: Record<string, unknown>
try {
parsedPayload = pulseDraft.structuredPayload.trim() ? JSON.parse(pulseDraft.structuredPayload) : {}
} catch {
setStatusMessage('Structured payload must be valid JSON.')
return
}
const timestamp = nowIso()
const pulse: PulseEvent = {
id: selectedPulseId ?? `pulse_${Date.now().toString(36)}`,
@@ -379,7 +419,6 @@ function App() {
agent_id: pulseDraft.agentId.trim() || appState.settings.default_agent_id,
pulse_type: pulseDraft.pulseType,
message: pulseDraft.message.trim(),
structured_payload: parsedPayload,
confidence_score: Number(pulseDraft.confidence) || 0,
evidence_refs: linesToArray(pulseDraft.evidenceRefs),
trace_id: pulseDraft.traceId.trim() || undefined,
@@ -834,10 +873,6 @@ function App() {
Evidence refs (one per line)
<textarea rows={4} value={pulseDraft.evidenceRefs} onChange={(event) => setPulseDraft((current) => ({ ...current, evidenceRefs: event.target.value }))} />
</label>
<label className="full-span">
Structured payload JSON
<textarea rows={4} value={pulseDraft.structuredPayload} onChange={(event) => setPulseDraft((current) => ({ ...current, structuredPayload: event.target.value }))} />
</label>
</div>
<div className="button-row">
<button type="button" onClick={savePulse}>{selectedPulse ? 'Save Changes' : 'Add Pulse'}</button>
@@ -971,7 +1006,7 @@ function App() {
<footer className="status-bar">
<span>{statusMessage}</span>
<span>Stored locally · schema {appState.schema_version}</span>
<span>{backendMode === 'appwrite' ? 'Appwrite backend · Unraid server' : backendMode === 'connecting' ? 'Connecting to Appwrite…' : 'Local cache fallback'} · schema {appState.schema_version}</span>
</footer>
</div>
)
+1 -3
View File
@@ -7,7 +7,7 @@ export const createSeedState = (): AppState => ({
project: {
id: 'project_buildpulse',
name: 'BuildPulse',
one_line_pitch: 'A local-first planning cockpit for AI-assisted product building.',
one_line_pitch: 'A calm planning cockpit for AI-assisted product building.',
description:
'BuildPulse helps capture features, park distracting ideas, log progress as Pulse events, and export clean context for AI coding agents.',
current_goal: 'Ship v0.1 with Feature Plan, Parking Lot, Pulse Log, and Export.',
@@ -120,7 +120,6 @@ export const createSeedState = (): AppState => ({
agent_id: 'jimmi',
pulse_type: 'INTENT',
message: 'Start BuildPulse as a calm single-project cockpit, not a full agent framework.',
structured_payload: {},
confidence_score: 0.95,
evidence_refs: ['docs/PRODUCT_BRIEF.md', 'docs/SCOPE.md'],
trace_id: 'session_seed',
@@ -133,7 +132,6 @@ export const createSeedState = (): AppState => ({
agent_id: 'jimmi',
pulse_type: 'DECISION',
message: 'Park AI triage, releases, and integrations until the manual workflow proves itself.',
structured_payload: {},
confidence_score: 0.9,
evidence_refs: ['docs/DECISIONS.md', 'docs/PARKING_LOT.md'],
trace_id: 'session_seed',
+34
View File
@@ -0,0 +1,34 @@
import type { AppState } from './types'
const API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || ''
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init?.headers || {}),
},
...init,
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed: ${response.status}`)
}
return response.json() as Promise<T>
}
export const fetchRemoteState = async () => {
const payload = await request<{ ok: boolean; state: AppState | null }>('/api/state')
return payload.state
}
export const pushRemoteState = async (state: AppState) => {
await request<{ ok: boolean }>('/api/state', {
method: 'PUT',
body: JSON.stringify({ state }),
})
}
export const fetchBackendHealth = async () => request<{ ok: boolean; backend: string; rows: number }>('/api/health')
-1
View File
@@ -80,7 +80,6 @@ export interface PulseEvent {
agent_id: string
pulse_type: PulseType
message: string
structured_payload: Record<string, unknown>
confidence_score: number
evidence_refs: string[]
trace_id?: string