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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user