feat(curator): pending-action API routes + client helpers (C4/5)

The HTTP surface for the review queue. Three endpoints, all under
the existing /api/journal blueprint to keep the journal-related routes
together:

- GET    /api/journal/pending — list current user's pending actions.
- POST   /api/journal/pending/<id>/approve — replay the proposed tool
         call via execute_tool(authority='user'). On success, marks
         the row 'approved'; on replay error, leaves it pending so
         the user can retry.
- POST   /api/journal/pending/<id>/reject — marks 'rejected' with no
         execution.

Each route is a thin wrapper around services/pending_actions and
delegates user-scoping to the service (which checks user_id on every
load — actions are private to the proposer).

api/client.ts:
- PendingCuratorAction interface mirroring the backend dict shape:
  id, user_id, conv_id, action_type, target_type/id/label, payload,
  current_snapshot, status, timestamps.
- listPendingActions / approvePendingAction / rejectPendingAction
  helpers for the upcoming Needs Review panel.

C5 next: the panel itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 10:09:40 -04:00
parent 3a316551be
commit 4048a771d2
2 changed files with 65 additions and 0 deletions
+30
View File
@@ -423,6 +423,36 @@ export async function runJournalCurator(convId: number): Promise<CuratorRunResul
return apiPost<CuratorRunResult>(`/api/journal/curator/run/${convId}`, {});
}
// Curator-proposed mutations awaiting user review (Needs Review panel).
// See routes/journal.py pending_actions endpoints.
export interface PendingCuratorAction {
id: number;
user_id: number;
conv_id: number | null;
action_type: string; // e.g. "update_note", "delete_note", "update_milestone"
target_type: string | null; // "task" | "note" | "milestone" | "project" | "profile"
target_id: number | null;
target_label: string | null; // human-readable title for the card header
payload: Record<string, unknown>; // the curator's proposed args
current_snapshot: Record<string, unknown>; // target state at proposal time
status: 'pending' | 'approved' | 'rejected';
created_at: string;
reviewed_at: string | null;
}
export async function listPendingActions(): Promise<{ pending: PendingCuratorAction[]; count: number }> {
return apiGet<{ pending: PendingCuratorAction[]; count: number }>('/api/journal/pending');
}
export async function approvePendingAction(actionId: number): Promise<Record<string, unknown>> {
return apiPost<Record<string, unknown>>(`/api/journal/pending/${actionId}/approve`, {});
}
export async function rejectPendingAction(actionId: number): Promise<Record<string, unknown>> {
return apiPost<Record<string, unknown>>(`/api/journal/pending/${actionId}/reject`, {});
}
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
+35
View File
@@ -271,6 +271,41 @@ async def list_moments():
return jsonify({"moments": results})
@journal_bp.get("/pending")
@login_required
async def list_pending_actions():
"""List curator-proposed mutations awaiting the user's review."""
user_id = get_current_user_id()
from fabledassistant.services.pending_actions import list_pending
pending = await list_pending(user_id)
return jsonify({"pending": pending, "count": len(pending)})
@journal_bp.post("/pending/<int:action_id>/approve")
@login_required
async def approve_pending_action(action_id: int):
"""Approve a proposed action — replays the underlying tool call.
Returns the tool result on success. If the replay errors (e.g., the
target was deleted in the meantime), the action stays pending so the
user can re-try or reject explicitly.
"""
user_id = get_current_user_id()
from fabledassistant.services.pending_actions import approve
result = await approve(action_id, user_id)
return jsonify(result)
@journal_bp.post("/pending/<int:action_id>/reject")
@login_required
async def reject_pending_action(action_id: int):
"""Reject a proposed action — marks rejected without executing anything."""
user_id = get_current_user_id()
from fabledassistant.services.pending_actions import reject
result = await reject(action_id, user_id)
return jsonify(result)
@journal_bp.patch("/moments/<int:moment_id>")
@login_required
async def patch_moment(moment_id: int):