refactor(core): overhaul state transition architecture, persistent job queue, and API layer
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 1m59s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 2m26s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 2m37s

- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission.
- Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication.
- Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse).
- Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization.
- Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5).
- Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations.
- Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets.
- Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels.
- Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar.
- Add architectural documentation and worklog in docs/009_worklog.md.
This commit is contained in:
2026-09-01 00:41:05 -07:00
parent 271a542196
commit ebcaa5d72a
38 changed files with 3748 additions and 1202 deletions
+201 -27
View File
@@ -2,6 +2,7 @@ package rules
import (
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
@@ -16,8 +17,16 @@ func NewEvaluator() *Evaluator {
return &Evaluator{}
}
// Evaluate checks whether a FeedEntry satisfies a Rule's conditions.
// Evaluate checks whether a single FeedEntry satisfies a Rule's conditions (legacy / static evaluation).
func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
return e.EvaluateTransition(rule, nil, &entry, nil)
}
// EvaluateTransition evaluates a rule against the (OldState, NewState, EntryDiff) transition delta.
func (e *Evaluator) EvaluateTransition(rule models.Rule, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
if newEntry == nil {
return false
}
if len(rule.Conditions) == 0 {
return true
}
@@ -25,7 +34,7 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
matchMode := strings.ToLower(rule.MatchMode)
if matchMode == "" || matchMode == "all" {
for _, cond := range rule.Conditions {
if !e.evaluateCondition(cond, entry) {
if !e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
return false
}
}
@@ -34,20 +43,109 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
// matchMode == "any"
for _, cond := range rule.Conditions {
if e.evaluateCondition(cond, entry) {
if e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
return true
}
}
return false
}
func (e *Evaluator) evaluateCondition(cond models.RuleCondition, entry models.FeedEntry) bool {
actualValue := e.resolveField(cond.Field, entry)
return e.compare(actualValue, cond.Operator, cond.Value)
func (e *Evaluator) evaluateTransitionCondition(cond models.RuleCondition, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
opLower := strings.ToLower(strings.TrimSpace(cond.Operator))
// 1. Transition predicates: became_present, changed, removed
switch opLower {
case "became_present", "added":
return e.checkBecamePresent(cond.Field, oldEntry, newEntry, diff)
case "changed", "updated":
return e.checkFieldChanged(cond.Field, oldEntry, newEntry, diff)
case "removed", "deleted":
return e.checkFieldRemoved(cond.Field, oldEntry, newEntry, diff)
}
// 2. Standard state guards & value comparisons evaluated on NewState
actualValue := e.resolveField(cond.Field, newEntry)
return e.compare(actualValue, opLower, cond.Value)
}
func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface{} {
switch strings.ToLower(field) {
func (e *Evaluator) checkBecamePresent(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
if newEntry == nil {
return false
}
// If diff is available, check direct addition
if strings.HasPrefix(field, "variants.") {
vKey := strings.TrimPrefix(field, "variants.")
if strings.Contains(vKey, ".") {
vKey = strings.Split(vKey, ".")[0]
}
if diff != nil && diff.HasVariantAddition(vKey) {
return true
}
// Fallback comparison between oldEntry and newEntry
oldHas := oldEntry != nil && oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
newHas := newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
return !oldHas && newHas
}
if field == "metadata.tags" || field == "tags" {
oldTags := getTagsSlice(oldEntry)
newTags := getTagsSlice(newEntry)
return len(oldTags) == 0 && len(newTags) > 0
}
oldVal := e.resolveField(field, oldEntry)
newVal := e.resolveField(field, newEntry)
return (oldVal == nil || fmt.Sprintf("%v", oldVal) == "") && (newVal != nil && fmt.Sprintf("%v", newVal) != "")
}
func (e *Evaluator) checkFieldChanged(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
if oldEntry == nil || newEntry == nil {
return false
}
if strings.HasPrefix(field, "variants.") {
vKey := strings.TrimPrefix(field, "variants.")
if strings.Contains(vKey, ".") {
vKey = strings.Split(vKey, ".")[0]
}
if diff != nil && (diff.HasVariantAddition(vKey) || diff.HasVariantChange(vKey)) {
return true
}
}
oldVal := e.resolveField(field, oldEntry)
newVal := e.resolveField(field, newEntry)
return !reflect.DeepEqual(oldVal, newVal)
}
func (e *Evaluator) checkFieldRemoved(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
if oldEntry == nil {
return false
}
if strings.HasPrefix(field, "variants.") {
vKey := strings.TrimPrefix(field, "variants.")
if diff != nil {
for _, r := range diff.VariantsRemoved {
if r == vKey {
return true
}
}
}
oldHas := oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
newHas := newEntry != nil && newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
return oldHas && !newHas
}
oldVal := e.resolveField(field, oldEntry)
newVal := e.resolveField(field, newEntry)
return (oldVal != nil && fmt.Sprintf("%v", oldVal) != "") && (newVal == nil || fmt.Sprintf("%v", newVal) == "")
}
func (e *Evaluator) resolveField(field string, entry *models.FeedEntry) interface{} {
if entry == nil {
return nil
}
fieldLower := strings.ToLower(strings.TrimSpace(field))
switch fieldLower {
case "feed_id":
return entry.FeedID
case "feed_title":
@@ -83,16 +181,34 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
return entry.PublishedAt
case "created_at":
return entry.CreatedAt
case "tags", "metadata.tags":
return getTagsSlice(entry)
}
// Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags"
if strings.HasPrefix(field, "variants.") {
variantKey := strings.TrimPrefix(field, "variants.")
// Variant resolution: "variants.ai_summary.word_count", "variants.ai_summary", "variants.readability_full_text"
if strings.HasPrefix(fieldLower, "variants.") {
path := strings.TrimPrefix(fieldLower, "variants.")
parts := strings.Split(path, ".")
vKey := parts[0]
if entry.Variants != nil {
if v, ok := entry.Variants[variantKey]; ok {
if v, ok := entry.Variants[vKey]; ok {
if len(parts) > 1 {
subProp := parts[1]
if subProp == "word_count" {
return float64(len(strings.Fields(v.TextValue)))
}
if subProp == "char_count" || subProp == "length" {
return float64(len(v.TextValue))
}
if v.Metadata != nil {
return v.Metadata[subProp]
}
}
return v.TextValue
}
}
return nil
}
return nil
@@ -101,35 +217,85 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
opLower := strings.ToLower(strings.TrimSpace(op))
// Handle exists / not_exists operators
if opLower == "exists" {
if actual == nil {
return false
}
if s, ok := actual.(string); ok && s == "" {
return false
}
return true
}
if opLower == "not_exists" {
// Handle is_empty / is_present / exists / not_exists
if opLower == "is_empty" || opLower == "not_exists" {
if actual == nil {
return true
}
if s, ok := actual.(string); ok && s == "" {
if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
return true
}
if slice, ok := actual.([]string); ok && len(slice) == 0 {
return true
}
return false
}
if opLower == "is_present" || opLower == "exists" {
if actual == nil {
return false
}
if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
return false
}
if slice, ok := actual.([]string); ok && len(slice) == 0 {
return false
}
return true
}
if actual == nil {
return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains"
}
// Handle Time/Timestamp comparisons
// Handle numeric comparisons (word_count >= 500, etc.)
if actualNum, ok := toFloat64(actual); ok == nil {
if expNum, okExp := toFloat64(expected); okExp == nil {
switch opLower {
case ">=":
return actualNum >= expNum
case ">":
return actualNum > expNum
case "<=":
return actualNum <= expNum
case "<":
return actualNum < expNum
case "eq", "==":
return actualNum == expNum
case "neq", "!=":
return actualNum != expNum
}
}
}
// Handle Time comparisons
if actualTime, ok := actual.(time.Time); ok {
return e.compareTime(actualTime, opLower, expected)
}
// Handle slice containment (e.g. tags contains "Tech")
if actualSlice, ok := actual.([]string); ok {
expectedStr := fmt.Sprintf("%v", expected)
switch opLower {
case "contains", "in":
for _, item := range actualSlice {
if strings.EqualFold(item, expectedStr) {
return true
}
}
return false
case "contains_any":
tokens := parseStringList(expected)
for _, item := range actualSlice {
for _, t := range tokens {
if strings.EqualFold(item, t) {
return true
}
}
}
return false
}
}
actualStr := fmt.Sprintf("%v", actual)
expectedStr := fmt.Sprintf("%v", expected)
@@ -218,6 +384,16 @@ func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bo
return false
}
func getTagsSlice(entry *models.FeedEntry) []string {
if entry == nil || entry.Metadata == nil {
return nil
}
if raw, ok := entry.Metadata["tags"]; ok {
return parseStringList(raw)
}
return nil
}
func parseStringList(val interface{}) []string {
var result []string
clean := func(s string) string {
@@ -279,7 +455,6 @@ func parseTime(val interface{}) (time.Time, error) {
func ValidateRuleSafety(rule models.Rule) error {
trigger := strings.ToLower(rule.Trigger)
// If trigger is on_variant_updated / variant_change, check for recursive set_variant on same key
if trigger == "on_variant_updated" || trigger == "variant_change" {
for _, action := range rule.Actions {
if action.Type == "set_variant" {
@@ -288,7 +463,6 @@ func ValidateRuleSafety(rule models.Rule) error {
targetKey, _ = action.Params["key"].(string)
}
// Check if trigger targets this exact key without safe conditions
for _, cond := range rule.Conditions {
if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") {
return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey)