From 3a5544a9e64d97e77be8ffa36e218ab8fe0c3131 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 4 May 2026 16:18:08 -0700 Subject: [PATCH] feat(memory tab): add Edit affordance with optimistic-locking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory tab supported only Add+Delete. Correcting an entry meant deleting and re-adding, losing the row's version counter and any concurrent-write guard the agent depends on. Now: per-row Edit button reveals an inline editor (value textarea + TTL). Save POSTs to the existing /memory upsert endpoint with if_match_version pinned to the entry's current version. On 409 the UI surfaces a retry hint and reloads. Tests: - 11 vitest cases covering pre-fill (JSON vs string), payload shape (parsed JSON, fallback to plain text, TTL inclusion/omission), cancel, 409 retry path, generic error path, and the no-version back-compat case. - E2E gate 9c in test_staging_full_saas.sh: seed → GET version → conditional update → assert new value → stale-version POST must 409. Pins the optimistic-locking contract end-to-end on staging. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/MemoryTab.tsx | 143 ++++++++++-- .../tabs/__tests__/MemoryTab.edit.test.tsx | 220 ++++++++++++++++++ tests/e2e/test_staging_full_saas.sh | 47 +++- 3 files changed, 395 insertions(+), 15 deletions(-) create mode 100644 canvas/src/components/tabs/__tests__/MemoryTab.edit.test.tsx diff --git a/canvas/src/components/tabs/MemoryTab.tsx b/canvas/src/components/tabs/MemoryTab.tsx index 2c5055f7..4243786f 100644 --- a/canvas/src/components/tabs/MemoryTab.tsx +++ b/canvas/src/components/tabs/MemoryTab.tsx @@ -10,6 +10,7 @@ interface Props { interface MemoryEntry { key: string; value: unknown; + version?: number; expires_at: string | null; updated_at: string; } @@ -28,6 +29,10 @@ export function MemoryTab({ workspaceId }: Props) { const [newValue, setNewValue] = useState(""); const [newTTL, setNewTTL] = useState(""); const [error, setError] = useState(null); + const [editingKey, setEditingKey] = useState(null); + const [editValue, setEditValue] = useState(""); + const [editTTL, setEditTTL] = useState(""); + const [editError, setEditError] = useState(null); const awarenessUrl = useMemo(() => { try { @@ -109,6 +114,69 @@ export function MemoryTab({ workspaceId }: Props) { } }; + const beginEdit = (entry: MemoryEntry) => { + setEditError(null); + setEditingKey(entry.key); + // Stringify objects/arrays as pretty JSON; render plain strings raw so the + // editor doesn't surprise users with surrounding quotes. + setEditValue( + typeof entry.value === "string" + ? entry.value + : JSON.stringify(entry.value, null, 2), + ); + if (entry.expires_at) { + const remainingMs = new Date(entry.expires_at).getTime() - Date.now(); + const ttl = Math.max(0, Math.floor(remainingMs / 1000)); + setEditTTL(ttl > 0 ? String(ttl) : ""); + } else { + setEditTTL(""); + } + }; + + const cancelEdit = () => { + setEditingKey(null); + setEditValue(""); + setEditTTL(""); + setEditError(null); + }; + + const handleEditSave = async (entry: MemoryEntry) => { + setEditError(null); + + let parsedValue: unknown; + try { + parsedValue = JSON.parse(editValue); + } catch { + parsedValue = editValue; + } + + // if_match_version closes the silent-overwrite hole when two writers + // race. The handler returns 409 with the current version on mismatch + // — surface that as a retry hint and reload to pick up the new state. + const body: Record = { key: entry.key, value: parsedValue }; + if (typeof entry.version === "number") { + body.if_match_version = entry.version; + } + if (editTTL) { + const ttl = parseInt(editTTL); + if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl; + } + + try { + await api.post(`/workspaces/${workspaceId}/memory`, body); + cancelEdit(); + loadMemory(); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to save"; + if (message.includes("409") || /if_match_version mismatch/i.test(message)) { + setEditError("This entry changed since you opened it. Reloading."); + loadMemory(); + } else { + setEditError(message); + } + } + }; + const openAwareness = () => { window.open(awarenessUrl, "_blank", "noopener,noreferrer"); }; @@ -308,24 +376,71 @@ export function MemoryTab({ workspaceId }: Props) { {expanded === entry.key && (
-
-                        {JSON.stringify(entry.value, null, 2)}
-                      
+ {editingKey === entry.key ? ( +
+