ipc+mavweb: add revert/undo endpoint to void latest fact for a key

- New store.VoidLatestFact() method finds latest non-voided fact for
  a key and writes a void-marker row pointing at it (transactional).
- New IPC method MethodRevertFact with CoreAPI.RevertFact interface,
  storeAPI adapter, server dispatch, and client proxy.
- New HTTP endpoint POST /api/revert?key=<key> in mavweb.
- History page adds a 'revert' button per non-voided fact row with
  JS confirmation and optimistic UI (marks row voided on success).
- All existing store, IPC, and mavweb tests pass.
This commit is contained in:
kami
2026-07-05 02:18:39 +04:00
parent ca081ce84d
commit a02e10fd11
7 changed files with 134 additions and 2 deletions
+26 -2
View File
@@ -4,7 +4,7 @@
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · history</title>
<style>
body{font:14px system-ui;margin:1.5rem;max-width:64rem}
body{font:14px system-ui;margin:1.5rem;max-width:70rem}
h1{font-size:1.3rem;margin-bottom:.3rem}
nav a{margin-right:1rem}
table{border-collapse:collapse;width:100%;margin-top:.5rem}
@@ -16,12 +16,18 @@
.val{font-family:monospace;word-break:break-all;max-width:20rem}
.src{font-size:.85rem;color:#666}
.count{color:#888;font-size:.85rem;margin-top:.3rem}
.revert-btn{font-size:.8rem;padding:.15rem .4rem;cursor:pointer}
.revert-btn:disabled{opacity:.4;cursor:not-allowed}
#msg{margin-top:.5rem;padding:.4rem;border-radius:.3rem;display:none}
.msg-ok{background:#e6ffed;display:block!important}
.msg-err{background:#ffe6e6;display:block!important}
</style>
<h1>maven · command history</h1>
<nav><a href=/dash>dash</a> <a href=/tools>tools</a> <a href=/auth/passkey>passkey</a></nav>
<p class=count>{{len .Facts}} facts shown (newest first)</p>
<div id=msg></div>
<table>
<tr><th>when<th>kind<th>key<th>value<th>source<th>conf</tr>
<tr><th>when<th>kind<th>key<th>value<th>source<th>conf<th></tr>
{{range .Facts}}<tr{{if .VoidsID}} class=voided{{end}}>
<td>{{if .VoidsID}}<span class=void-badge> voided</span>{{end}}{{.Ts.Format "2006-01-02 15:04"}}</td>
<td>{{.Kind}}</td>
@@ -29,5 +35,23 @@
<td class=val>{{.Value}}</td>
<td class=src>{{.Source}}</td>
<td>{{printf "%.2f" .Confidence}}</td>
<td>{{if not .VoidsID}}<button class=revert-btn onclick="revert('{{.Key}}',this)">revert</button>{{end}}</td>
</tr>{{end}}
</table>
<script>
const msg=document.getElementById('msg');
function revert(key,btn){
btn.disabled=true;
fetch('/api/revert',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'key='+encodeURIComponent(key)})
.then(r=>r.json().then(d=>({ok:r.ok,data:d})))
.then(({ok,data})=>{
msg.className=ok?'msg-ok':'msg-err';
msg.textContent=ok?'reverted ✓':(data.error||'revert failed');
if(ok) btn.closest('tr').className='voided';
}).catch(e=>{
msg.className='msg-err';
msg.textContent='error: '+e;
btn.disabled=false;
});
}
</script>
+33
View File
@@ -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
+4
View File
@@ -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
+10
View File
@@ -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)
+18
View File
@@ -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)
+1
View File
@@ -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
+42
View File
@@ -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
}