diff --git a/cmd/mavweb/history.html b/cmd/mavweb/history.html index bd39934..08dea56 100644 --- a/cmd/mavweb/history.html +++ b/cmd/mavweb/history.html @@ -4,7 +4,7 @@ maven · history

maven · command history

{{len .Facts}} facts shown (newest first)

+
- + {{range .Facts}} @@ -29,5 +35,23 @@ +{{end}}
whenkindkeyvaluesourceconf
whenkindkeyvaluesourceconf
{{if .VoidsID}}⨯ voided{{end}}{{.Ts.Format "2006-01-02 15:04"}} {{.Kind}}{{.Value}} {{.Source}} {{printf "%.2f" .Confidence}}{{if not .VoidsID}}{{end}}
+ diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index aecc72d..422262a 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -133,6 +133,9 @@ func main() { mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) { handleHistory(w, r, core) }) + mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { + handleRevert(w, r, core) + }) // ----- passkey (WebAuthn) endpoints ----- // Wired when both -core and a configured origin are present. The origin // must match the browser's view of mavweb (e.g. https://maven.kvmx.ru). @@ -385,6 +388,36 @@ func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } +func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + if core == nil { + http.Error(w, "revert disabled (no -core)", http.StatusServiceUnavailable) + return + } + key := strings.TrimSpace(r.FormValue("key")) + if key == "" { + http.Error(w, "key required", http.StatusBadRequest) + return + } + ctx := r.Context() + newID, err := core.RevertFact(ctx, key) + if err != nil { + log.Printf("revert %q: %v", key, err) + if errors.Is(err, ipc.ErrNoFact) { + http.Error(w, "no fact to revert", http.StatusNotFound) + return + } + http.Error(w, "revert failed", http.StatusBadGateway) + return + } + log.Printf("reverted fact for key=%s, new_id=%d", key, newID) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID}) +} + // handleTools serves the enable surface (GET) and applies an enable (POST). // POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is // whitespace-split — argv with embedded spaces isn't supported (ponytail: no diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 5ccf484..4b82e90 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -121,6 +121,9 @@ type outcomesReq struct { type nReq struct { N int `json:"n"` } +type revertReq struct { + Key string `json:"key"` +} type writeNoteReq struct { Ts time.Time `json:"ts"` Text string `json:"text"` @@ -229,6 +232,7 @@ type CoreAPI interface { DisableTool(ctx context.Context, name string) error LookupTool(ctx context.Context, name string) (Tool, error) ListTools(ctx context.Context, status string) ([]Tool, error) + RevertFact(ctx context.Context, key string) (int64, error) } // ErrToolNotFound — no tool row with this name (re-exported store sentinel for diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 8003c09..1633f7c 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -345,5 +345,15 @@ func (c *Client) ListTools(ctx context.Context, status string) ([]Tool, error) { return r.Tools, nil } +func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) { + var result struct { + NewID int64 `json:"new_id"` + } + if err := c.call(ctx, MethodRevertFact, map[string]string{"key": key}, &result); err != nil { + return 0, err + } + return result.NewID, nil +} + // Compile-time check: *Client satisfies CoreAPI. var _ CoreAPI = (*Client)(nil) \ No newline at end of file diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 7ad83ba..9a73f73 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -165,6 +165,11 @@ func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) { return toTool(t), nil } +func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) { + _, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now()) + return newID, mapErr(err) +} + func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) { ts, err := a.s.ListTools(ctx, status) if err != nil { @@ -607,6 +612,19 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er } return marshalResult(listToolsResp{Tools: out}), nil + case MethodRevertFact: + var p struct { + Key string `json:"key"` + } + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + newID, err := s.api.RevertFact(ctx, p.Key) + if err != nil { + return nil, err + } + return marshalResult(map[string]int64{"new_id": newID}), nil + case MethodAssertStepUp: if s.StepUp != nil { return marshalResult(nil), s.StepUp(ctx) diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index 1cc24c6..76c7748 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -34,6 +34,7 @@ const ( MethodAssertStepUp Method = "assert_stepup" MethodLookupTool Method = "lookup_tool" MethodListTools Method = "list_tools" + MethodRevertFact Method = "revert_fact" ) // Request — one frame from module to core. Params is the JSON-encoded argument diff --git a/internal/store/facts.go b/internal/store/facts.go index f048770..646d398 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -166,6 +166,48 @@ func (s *Store) CorrectValue(ctx context.Context, key, source string, value any, return newID, nil } +// VoidLatestFact voids the latest non-voided fact for key. It writes a new +// fact with voids_id pointing at the old one, keeping the audit trail intact. +// Returns the voided fact's ID and the new void-marker fact's ID. +// If no fact exists for the key, returns ErrNoFact. +func (s *Store) VoidLatestFact(ctx context.Context, key, source string, ts time.Time) (oldID, newID int64, err error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, 0, err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + err = tx.QueryRowContext(ctx, ` + SELECT id FROM facts + WHERE key = ? + AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL) + ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID) + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, ErrNoFact + } + if err != nil { + return 0, 0, fmt.Errorf("void: find latest: %w", err) + } + res, err := tx.ExecContext(ctx, + `INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`, + ts.UnixMilli(), string(KindSelf), key, `"voided"`, source, 1.0, + sql.NullInt64{Int64: oldID, Valid: true}) + if err != nil { + return 0, 0, fmt.Errorf("void: write void-marker: %w", err) + } + if err := tx.Commit(); err != nil { + return 0, 0, err + } + newID, err = res.LastInsertId() + if err != nil { + return 0, 0, fmt.Errorf("void: last insert id: %w", err) + } + return oldID, newID, nil +} + type rowScanner interface { Scan(dest ...any) error }