feat(01-02): chat mutations mutate demo state instead of acking (FED-04)
Demo images / Build & push demo images (push) Successful in 4m21s

Reactions, replies, read-receipts, edits, deletes, forwards and channel sends
shared one bare `{ ok: true, sent: true }` case, so none of them rendered on
the demo — the UI derives reaction chips and reply quotes from the message
store, and there was nothing in it to derive from.

Each now mirrors its daemon counterpart. Reactions/replies/receipts push typed
messages carrying the { sender_pubkey, sender_seq } target key Mesh.vue's
reactionIndex and replyTargetPreview read. Edits rewrite the text and set
edited_at; deletes tombstone IN PLACE (plaintext, typed_payload.deleted,
message_type 'delete') because that is what mesh/mod.rs apply_local_delete
does — it does not remove the row.

Edits and deletes go through a per-session overrides overlay keyed by
sender_seq, because mesh.messages rebuilds its seed array on every read, so
in-place mutation would only ever work for messages sent this session.

mesh.refresh and mesh.reboot-radio stay acknowledgements on purpose — the
daemon's handlers have no message-store effect either — with a comment saying
so, so a later reader does not "fix" them into divergence.

Also completes the phase bookkeeping for 01-02/03/11/12/13/14/15 and lands the
orphaned 01-12/01-14 SUMMARYs.

Verified: parity harness 17/17 live assertions; full frontend suite 102 files
/ 822 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-01 06:37:00 -04:00
co-authored by Claude Opus 5
parent 9c2ec82bdb
commit 90884e6259
8 changed files with 786 additions and 41 deletions
+170 -11
View File
@@ -136,6 +136,63 @@ const DEMO_MESH_PEERS = [
// reported it (the seed values are offsets in ms).
const DEMO_PEER_LAST_HEARD_MS = { 1: 30000, 2: 120000, 3: 600000, 4: 45000 }
const DEMO_PEER_NAMES = Object.fromEntries(
DEMO_MESH_PEERS.map((p) => [p.contact_id, p.advert_name]),
)
// This node's own key on the demo. Reactions/replies the visitor sends are
// direction:'sent', which is what the UI keys '__self__' off — but the field
// still has to be present and stable for the message key to work.
const DEMO_SELF_PUBKEY = 'demo02abababababababababababababababababababababababababababab'
// Append a message to this session's mesh store, matching the field set of
// mesh/types.rs MeshMessage (id, direction, peer_contact_id, peer_name,
// plaintext, timestamp, delivered, encrypted, transport, message_type,
// typed_payload, sender_pubkey, sender_seq).
function pushMeshMessage({
contact_id = null,
plaintext = '',
message_type = 'text',
typed_payload = null,
transport = 'meshcore',
channel = null,
}) {
const meshStore = currentStore().mesh
const id = 100 + meshStore.dynamic.length
const msg = {
id,
direction: 'sent',
peer_contact_id: contact_id,
peer_name: contact_id === null ? (channel === null ? null : `channel-${channel}`) : DEMO_PEER_NAMES[contact_id] || `peer-${contact_id}`,
plaintext,
timestamp: new Date().toISOString(),
delivered: true,
// mountain-node (3) has no pubkey, so traffic to it is unencrypted — the
// same asymmetry the rest of the demo already models.
encrypted: contact_id !== 3,
transport,
sender_pubkey: DEMO_SELF_PUBKEY,
sender_seq: id,
message_type,
typed_payload,
}
if (channel !== null) msg.channel = channel
meshStore.dynamic.push(msg)
return msg
}
// Apply this session's edit/delete overrides over a message list. Keyed by
// sender_seq, mirroring mesh/mod.rs apply_local_edit / apply_local_delete,
// which match an own-Sent message by sender_seq. Kept as an overlay rather
// than mutating the seed array, because the seed is rebuilt on every read.
function applyMeshOverrides(messages) {
const overrides = currentStore().mesh.overrides
if (!overrides || Object.keys(overrides).length === 0) return messages
return messages.map((m) => {
const o = m.direction === 'sent' && m.sender_seq != null ? overrides[m.sender_seq] : null
return o ? { ...m, ...o } : m
})
}
// Boot mode: simulate server startup delay
let BOOT_START_TIME = Date.now()
const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis)
@@ -3265,7 +3322,7 @@ app.post('/rpc/v1', (req, res) => {
]
// Messages sent this session (texts + attachments) ride after the
// static seed so refresh-after-send shows them, same as a real node.
const withDynamic = [...allMessages, ...currentStore().mesh.dynamic]
const withDynamic = applyMeshOverrides([...allMessages, ...currentStore().mesh.dynamic])
return res.json({
result: {
messages: withDynamic.slice(0, limit),
@@ -4844,19 +4901,121 @@ app.post('/rpc/v1', (req, res) => {
})
}
// Chat actions the demo only needs to acknowledge.
//
// ── Chat mutations ──────────────────────────────────────────────────
// Reactions, replies, receipts and forwards are new typed messages
// carrying a target key (typed_messages.rs send_typed_wire); edits and
// deletes mutate the target in place (mesh/mod.rs apply_local_edit /
// apply_local_delete). The UI derives reaction chips and reply quotes
// from exactly these shapes (Mesh.vue reactionIndex /
// replyTargetPreview), so a bare ack renders as nothing at all.
case 'mesh.send-reaction': {
const emoji = params?.emoji ?? ''
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: emoji,
message_type: 'reaction',
typed_payload: {
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
// An empty emoji clears this reactor's reaction, per the UI's
// reactionIndex (freshest per reactor wins; '' deletes).
emoji,
},
})
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.send-reply': {
const text = params?.text ?? ''
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: text,
message_type: 'reply',
typed_payload: {
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
text,
},
})
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.send-read-receipt': {
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: '',
message_type: 'read_receipt',
typed_payload: {
target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq },
},
})
return res.json({ result: { ok: true, sent: true } })
}
// apply_local_edit: replaces plaintext and merges { edited_at, text }
// into the existing typed_payload of the own-Sent message with that seq.
case 'mesh.edit-message': {
const seq = params?.target_seq
const newText = params?.new_text ?? ''
if (seq == null) {
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
}
const overrides = currentStore().mesh.overrides
const prior = overrides[seq] || {}
overrides[seq] = {
...prior,
plaintext: newText,
typed_payload: {
...(prior.typed_payload || {}),
edited_at: Math.floor(Date.now() / 1000),
text: newText,
},
}
return res.json({ result: { ok: true, sent: true } })
}
// apply_local_delete: tombstone in place — the daemon does NOT remove
// the row, it rewrites plaintext, sets typed_payload { deleted: true }
// and message_type 'delete'. Mirrored exactly; do not "simplify" this
// into a splice, the UI renders the tombstone bubble.
case 'mesh.delete-message': {
const seq = params?.target_seq
if (seq == null) {
return res.json({ error: { code: -32602, message: 'target_seq is required' } })
}
currentStore().mesh.overrides[seq] = {
plaintext: '🗑 message deleted',
typed_payload: { deleted: true },
message_type: 'delete',
}
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.forward-message': {
const sourceId = params?.source_message_id
const all = applyMeshOverrides(currentStore().mesh.dynamic)
const source = all.find((m) => m.id === sourceId)
pushMeshMessage({
contact_id: params?.contact_id ?? null,
plaintext: source ? source.plaintext : '(forwarded)',
message_type: source?.message_type === 'content_ref' ? 'content_ref' : 'text',
typed_payload: source?.typed_payload ?? null,
})
return res.json({ result: { ok: true, sent: true } })
}
case 'mesh.send-channel': {
pushMeshMessage({
contact_id: null,
channel: params?.channel ?? 0,
plaintext: params?.message ?? '',
message_type: 'text',
})
return res.json({ result: { ok: true, sent: true } })
}
// mesh.refresh and mesh.reboot-radio stay bare acknowledgements ON
// PURPOSE: the daemon's handlers have no message-store effect either
// (refresh re-polls the radio, reboot-radio power-cycles it), so giving
// them demo-side state would be divergence, not parity. Do not "fix".
case 'mesh.send-reaction':
case 'mesh.send-reply':
case 'mesh.send-read-receipt':
case 'mesh.edit-message':
case 'mesh.delete-message':
case 'mesh.forward-message':
case 'mesh.send-channel':
case 'mesh.refresh':
case 'mesh.reboot-radio': {
return res.json({ result: { ok: true, sent: true } })
@@ -5877,7 +6036,7 @@ function makeSessionStore() {
// contacts: pubkey_hex → { alias, notes, pinned, blocked }, mirroring the
// daemon's state.contacts map. scheduled: queued messages awaiting their
// fire_at, mirroring svc.scheduler's list.
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1 },
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1, overrides: {} },
sockets: new Set(),
lastSeen: Date.now(),
}
+63 -1
View File
@@ -214,7 +214,69 @@ try {
else fail('cancelled request still listed as pending')
}
// 6. federation.notify-did-change reports per-peer results.
// 6. Chat mutations are observable on the next mesh.messages read — the
// whole point of FED-04's "not a bare ok acknowledgement" requirement.
const sent = await rpc('mesh.send-content-inline', {
contact_id: 1,
mime: 'text/plain',
filename: 'mutate.txt',
bytes_b64: Buffer.from('mutate me').toString('base64'),
})
const targetSeq = sent.message_id
await rpc('mesh.send-reaction', {
contact_id: 1,
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
target_seq: targetSeq,
emoji: '🔥',
})
let msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const reaction = msgs.find(
(m) => m.message_type === 'reaction' && m.typed_payload?.target?.sender_seq === targetSeq,
)
if (reaction?.typed_payload?.emoji === '🔥') pass('mesh.send-reaction is visible as a reaction message with a target key')
else fail('reaction did not appear in mesh.messages with the UI-expected shape')
await rpc('mesh.send-reply', {
contact_id: 1,
target_pubkey: 'demo02abababababababababababababababababababababababababababab',
target_seq: targetSeq,
text: 'replying to that',
})
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const reply = msgs.find((m) => m.message_type === 'reply' && m.typed_payload?.target?.sender_seq === targetSeq)
if (reply?.plaintext === 'replying to that') pass('mesh.send-reply is visible as a reply carrying its target')
else fail('reply did not appear with a target key')
await rpc('mesh.edit-message', { contact_id: 1, target_seq: targetSeq, new_text: 'edited text' })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const edited = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
if (edited?.plaintext === 'edited text' && edited?.typed_payload?.edited_at)
pass('mesh.edit-message rewrote the text and set the edited marker')
else fail(`edit not applied: ${JSON.stringify(edited?.plaintext)}`)
await rpc('mesh.delete-message', { contact_id: 1, target_seq: targetSeq })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const deleted = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent')
// The daemon tombstones in place rather than removing the row.
if (deleted && deleted.message_type === 'delete' && deleted.typed_payload?.deleted === true)
pass('mesh.delete-message tombstoned in place, as apply_local_delete does')
else fail(`delete representation wrong: ${JSON.stringify(deleted)}`)
const beforeForward = (await rpc('mesh.messages', { limit: 500 })).count
await rpc('mesh.forward-message', { contact_id: 2, source_message_id: targetSeq })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
const forwarded = msgs.filter((m) => m.peer_contact_id === 2 && m.direction === 'sent')
if (msgs.length > beforeForward && forwarded.length > 0) pass('mesh.forward-message pushed a copy for the destination peer')
else fail('forward did not create a message for the destination peer')
await rpc('mesh.send-channel', { channel: 3, message: 'channel broadcast' })
msgs = (await rpc('mesh.messages', { limit: 500 })).messages
if (msgs.some((m) => m.channel === 3 && m.plaintext === 'channel broadcast'))
pass('mesh.send-channel pushed a channel-addressed message')
else fail('channel message not visible in mesh.messages')
// 7. federation.notify-did-change reports per-peer results.
const notified = await rpc('federation.notify-did-change', {
old_did: 'did:key:zOld',
new_did: 'did:key:zNew',