Merge branch 'fix/g11' into fix/integrated

# Conflicts:
#	internal/store/migrations.go
This commit is contained in:
kami
2026-08-01 14:20:24 +04:00
19 changed files with 1070 additions and 266 deletions
+21
View File
@@ -24,6 +24,23 @@ type Fact struct {
VoidsID *int64 `json:"voids_id,omitempty"`
}
// EcosystemTrace — one hop of a cross-service ecosystem call, read by the
// monitoring surfaces. Traces live in their own store table, not in facts:
// they are written at machine rate and would otherwise crowd every bounded
// reader of facts.
type EcosystemTrace struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Service string `json:"service"`
Operation string `json:"operation"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
CorrelationID string `json:"correlation_id"`
CausationID string `json:"causation_id"`
HTTPStatus int `json:"http_status"`
Fields map[string]any `json:"fields,omitempty"`
}
// Bucket — presence hysteresis state: "present" | "away".
type Bucket string
@@ -616,6 +633,10 @@ type CoreAPI interface {
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
RecentNotes(ctx context.Context, n int) ([]Note, error)
+9
View File
@@ -65,6 +65,7 @@ var readOnlyMethods = map[Method]bool{
MethodRecentActiveFacts: true,
MethodCalendarEvents: true,
MethodRecentNudges: true,
MethodRecentEcoTraces: true,
MethodQueryNotes: true,
MethodRecentNotes: true,
MethodLookupTool: true,
@@ -351,6 +352,14 @@ func (c *Client) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact
return out, nil
}
func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
var out []EcosystemTrace
if err := c.call(ctx, MethodRecentEcoTraces, nReq{N: n}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
var out []Nudge
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
+26
View File
@@ -144,6 +144,22 @@ func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fa
return out, nil
}
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
trs, err := a.s.RecentEcosystemTraces(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]EcosystemTrace, len(trs))
for i, tr := range trs {
out[i] = EcosystemTrace{
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
}
}
return out, nil
}
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
ns, err := a.s.RecentNudges(ctx, n)
if err != nil {
@@ -799,6 +815,16 @@ var methodTable = map[Method]handlerFunc{
}
return out, nil
}),
MethodRecentEcoTraces: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]EcosystemTrace, error) {
out, err := api.RecentEcosystemTraces(ctx, p.N)
if err != nil {
return nil, err
}
if out == nil {
out = []EcosystemTrace{}
}
return out, nil
}),
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
out, err := api.RecentNudges(ctx, p.N)
if err != nil {
+3
View File
@@ -71,6 +71,9 @@ func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Ti
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
return 0, ErrNotImplemented
}
+1
View File
@@ -28,6 +28,7 @@ const (
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
MethodCalendarEvents Method = "calendar_events"
MethodRecentNudges Method = "recent_nudges"
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
MethodWriteNote Method = "write_note"
MethodQueryNotes Method = "query_notes"
MethodRecentNotes Method = "recent_notes"
+107
View File
@@ -0,0 +1,107 @@
package store
import (
"context"
"encoding/json"
"fmt"
"time"
)
// ecosystemTraceRetention is how many trace rows are kept. Traces are
// diagnostics with a short useful life, and they arrive at machine rate, so
// the table is bounded rather than append-only. The facts table is the audit
// trail; this one is not.
const ecosystemTraceRetention = 5000
// EcosystemTrace is one hop of a cross-service call: which service, which
// operation, how it ended, how long it took, and the ids that stitch the hops
// of one turn together. Fields carries the hop-specific detail (entity id,
// capability, failure class) as a JSON object.
type EcosystemTrace struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Service string `json:"service"`
Operation string `json:"operation"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
CorrelationID string `json:"correlation_id"`
CausationID string `json:"causation_id"`
HTTPStatus int `json:"http_status"`
Fields map[string]any `json:"fields"`
}
// WriteEcosystemTrace appends one trace row and keeps the table bounded.
func (s *Store) WriteEcosystemTrace(ctx context.Context, tr EcosystemTrace) (int64, error) {
fields := "{}"
if len(tr.Fields) > 0 {
b, err := json.Marshal(tr.Fields)
if err != nil {
return 0, fmt.Errorf("marshal trace fields: %w", err)
}
fields = string(b)
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO ecosystem_traces
(ts, service, operation, status, duration_ms, correlation_id, causation_id, http_status, fields)
VALUES (?,?,?,?,?,?,?,?,?)`,
tr.Ts.UnixMilli(), tr.Service, tr.Operation, tr.Status, tr.DurationMs,
tr.CorrelationID, tr.CausationID, tr.HTTPStatus, fields)
if err != nil {
return 0, fmt.Errorf("write ecosystem trace: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
// Prune rarely: the cost of the delete is not worth paying on every hop,
// and the bound is a ceiling, not an exact size.
if id%256 == 0 {
if err := s.PruneEcosystemTraces(ctx, ecosystemTraceRetention); err != nil {
return id, err
}
}
return id, nil
}
// PruneEcosystemTraces drops all but the newest keep rows.
func (s *Store) PruneEcosystemTraces(ctx context.Context, keep int) error {
if keep <= 0 {
return nil
}
_, err := s.db.ExecContext(ctx, `
DELETE FROM ecosystem_traces
WHERE id <= (SELECT MAX(id) FROM ecosystem_traces) - ?`, keep)
if err != nil {
return fmt.Errorf("prune ecosystem traces: %w", err)
}
return nil
}
// RecentEcosystemTraces returns the newest n traces, newest first.
func (s *Store) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, service, operation, status, duration_ms, correlation_id, causation_id, http_status, fields
FROM ecosystem_traces
ORDER BY id DESC
LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("recent ecosystem traces: %w", err)
}
defer rows.Close()
var out []EcosystemTrace
for rows.Next() {
var tr EcosystemTrace
var tsMilli int64
var fields string
if err := rows.Scan(&tr.ID, &tsMilli, &tr.Service, &tr.Operation, &tr.Status,
&tr.DurationMs, &tr.CorrelationID, &tr.CausationID, &tr.HTTPStatus, &fields); err != nil {
return nil, err
}
tr.Ts = time.UnixMilli(tsMilli).UTC()
if fields != "" {
_ = json.Unmarshal([]byte(fields), &tr.Fields)
}
out = append(out, tr)
}
return out, rows.Err()
}
+23
View File
@@ -177,6 +177,29 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
`ALTER TABLE tasks ADD COLUMN ext_id TEXT;
ALTER TABLE tasks ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_ext_id ON tasks (ext_id) WHERE ext_id IS NOT NULL;`,
// #16 — ecosystem call traces (Vikunja #273). Deliberately NOT facts.
// Traces are written at machine rate, one act turn produces three or four,
// while facts are written at human rate. Sharing the facts table made every
// bounded reader of facts (the habit profile's 2000-row window, memeval's
// prompt snapshot, /dash's 50 and /history's 200) read mostly traces after
// a day of ecosystem use, pushing the rows that matter out of range.
// Retention is enforced on write (PruneEcosystemTraces) because nothing
// here is an audit trail: a trace answers "did this hop work" for as long
// as anyone is still asking.
`CREATE TABLE IF NOT EXISTS ecosystem_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
service TEXT NOT NULL,
operation TEXT NOT NULL,
status TEXT NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
correlation_id TEXT NOT NULL DEFAULT '',
causation_id TEXT NOT NULL DEFAULT '',
http_status INTEGER NOT NULL DEFAULT 0,
fields TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_eco_traces_ts ON ecosystem_traces (ts DESC);
CREATE INDEX IF NOT EXISTS idx_eco_traces_correlation ON ecosystem_traces (correlation_id);`,
}
// migrate applies every migration with a number greater than the DB's current