fix(#6212): reconstruct Anchor outcomes from run journals#6295
fix(#6212): reconstruct Anchor outcomes from run journals#6295webtecnica wants to merge 2 commits into
Conversation
Closes common issue where clicking MEDIA: links for .md files could download a JSON auth error on desktop. Three-layer fix: 1. MIME mapping (api/config.py): add .md/.mkd/.mkdn → text/markdown so the server returns the correct Content-Type instead of application/octet-stream 2. Session allow-list (api/routes.py): add text/markdown to _SESSION_MEDIA_TOKEN_TYPES so .md files are accepted by the session-token path check 3. Frontend preview (static/ui.js + static/style.css): detect .md files in _inlineMediaHtmlForRef, fetch content via api/media, render through the existing renderMd() pipeline, and display inline in a styled container — same pattern as HTML/CSV previews
In _run_journal_live_snapshot(), accumulate artifact_reference and state_saved journal events into artifacts[] and side_effects[] lists, then include them in the returned anchor_activity_scene dict. This preserves turn-owned artifact and side-effect references during hard-refresh/reconnect/session-switch reconstruction from the durable run journal, closing the remaining consumer-side gap after nesquena#6204 and nesquena#6207. - Add artifacts=[] and side_effects=[] accumulators - Handle artifact_reference events (kind, path, event_id) - Handle state_saved events (kind, name, action, event_id) - Include artifacts and side_effects in anchor_activity_scene - Ignore malformed/empty payloads (no-op for safety)
|
| Filename | Overview |
|---|---|
| api/routes.py | Adds artifact/side_effect accumulators and event handlers in _run_journal_live_snapshot; hydrates the anchor_activity_scene with both lists. The full raw payload dict is stored verbatim alongside extracted fields; event_id uses a truthiness guard that would silently drop event_id=0. |
| static/ui.js | Adds _MD_EXTS regex, loadMarkdownInline() async fetcher, and wires it into postProcessRenderedMessages. Rendered markdown is sanitized by the existing renderMd() pipeline; Mermaid/KaTeX placeholders inside markdown file previews won't receive follow-up post-processing because renderMermaidBlocks/renderKatexBlocks run synchronously before the fetch resolves. |
| api/config.py | Adds .md, .mkd, .mkdn to MIME_MAP. Consistent with the JS extension regex and the session token allowlist change. |
| static/style.css | Adds markdown inline preview CSS classes (loading state, header bar, content area). Mirrors the existing HTML preview style conventions. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant C as Client (Browser)
participant S as Server (routes.py)
participant J as Run Journal
Note over C,J: Reconnect / hard-refresh / session switch
C->>S: GET /snapshot (stream_id)
S->>J: read_run_events(session_id, stream_id)
J-->>S: events[]
S->>S: Loop events
S->>S: artifact_reference to artifacts[]
S->>S: state_saved to side_effects[]
S-->>C: "anchor_activity_scene { artifacts, side_effects, activity_rows }"
Note over C,J: Markdown inline preview (new)
C->>C: postProcessRenderedMessages()
C->>C: loadMarkdownInline() async
C->>S: "fetch api/media?path=file.md"
S-->>C: text/markdown content
C->>C: renderMd(text) sanitize and render
C->>C: "el.outerHTML = rendered HTML"
Note right of C: renderMermaidBlocks and renderKatexBlocks already ran
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant C as Client (Browser)
participant S as Server (routes.py)
participant J as Run Journal
Note over C,J: Reconnect / hard-refresh / session switch
C->>S: GET /snapshot (stream_id)
S->>J: read_run_events(session_id, stream_id)
J-->>S: events[]
S->>S: Loop events
S->>S: artifact_reference to artifacts[]
S->>S: state_saved to side_effects[]
S-->>C: "anchor_activity_scene { artifacts, side_effects, activity_rows }"
Note over C,J: Markdown inline preview (new)
C->>C: postProcessRenderedMessages()
C->>C: loadMarkdownInline() async
C->>S: "fetch api/media?path=file.md"
S-->>C: text/markdown content
C->>C: renderMd(text) sanitize and render
C->>C: "el.outerHTML = rendered HTML"
Note right of C: renderMermaidBlocks and renderKatexBlocks already ran
Reviews (1): Last reviewed commit: "fix(#6212): reconstruct Anchor outcomes ..." | Re-trigger Greptile
| entry: dict = { | ||
| "source_event_type": "artifact_reference", | ||
| "payload": dict(payload), | ||
| } | ||
| for key in ("kind", "path"): | ||
| value = payload.get(key) | ||
| if value is not None: | ||
| entry[key] = str(value) | ||
| event_id = event.get("event_id") | ||
| if event_id: | ||
| entry["event_id"] = str(event_id) | ||
| artifacts.append(entry) |
There was a problem hiding this comment.
Raw payload stored alongside extracted fields
Each entry includes "payload": dict(payload) — a shallow copy of the entire original journal event payload — in addition to the explicitly extracted kind/path/event_id fields. If any future payload field is large or sensitive, this copy will silently widen what's sent to the frontend on every snapshot reconnect. The same pattern applies to state_saved entries.
Consider whether the raw payload passthrough is intentional; if it's only needed as a debugging fallback, restricting it to a development flag or omitting it entirely would reduce surface area.
| event_id = event.get("event_id") | ||
| if event_id: | ||
| entry["event_id"] = str(event_id) |
There was a problem hiding this comment.
Falsy guard silently drops
event_id = 0
if event_id: evaluates to False when event_id is the integer 0 or an empty string. If the journal backend ever uses zero-based auto-increment IDs or emits an empty-string sentinel, the entry would be returned without event_id, which could break consumer-side deduplication. A stricter guard like if event_id is not None: would be safer.
| }); | ||
| } | ||
|
|
||
| const MD_MAX_SIZE = 256 * 1024; | ||
|
|
||
| function loadMarkdownInline(container){ | ||
| const root = container || document; | ||
| root.querySelectorAll('.md-inline-load:not([data-loaded])').forEach(el => { | ||
| el.setAttribute('data-loaded', '1'); | ||
| const path = el.dataset.path; | ||
| const fname = path.split('/').pop() || path; | ||
| const mediaSessionId = (typeof S !== 'undefined' && S && S.session && S.session.session_id) ? String(S.session.session_id) : ''; | ||
| const publicMediaUrl = 'api/media?path=' + encodeURIComponent(path); | ||
| const mediaUrl = publicMediaUrl + (mediaSessionId ? '&session_id=' + encodeURIComponent(mediaSessionId) : ''); | ||
| const downloadUrl = mediaUrl + '&download=1'; | ||
| fetch(mediaUrl) | ||
| .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) | ||
| .then(text => { | ||
| if (text.length > MD_MAX_SIZE) { | ||
| el.outerHTML = `<div class="md-inline-fallback"><a class="msg-media-link" href="${esc(downloadUrl)}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${esc(typeof t === 'function' ? t('md_too_large') : 'File too large for preview')}</span></div>`; | ||
| return; | ||
| } | ||
| const rendered = renderMd(text); | ||
| el.outerHTML = `<div class="md-inline-wrap"><div class="md-inline-header"><span class="md-preview-title">${esc(fname)}</span><a class="msg-media-link" href="${esc(downloadUrl)}" download="${esc(fname)}">📎 ${esc(fname)}</a></div><div class="md-inline-content">${rendered}</div></div>`; | ||
| }) | ||
| .catch(() => { | ||
| el.outerHTML = `<div class="md-inline-fallback"><a class="msg-media-link" href="${esc(downloadUrl)}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${esc(typeof t === 'function' ? t('md_error') : 'Error loading markdown')}</span></div>`; | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function renderMermaidBlocks(container){ | ||
| const root=container||document; | ||
| const blocks=root.querySelectorAll('.mermaid-block:not([data-rendered])'); |
There was a problem hiding this comment.
Mermaid/KaTeX blocks inside markdown file previews will not render
loadMarkdownInline is asynchronous (fetch-based), so by the time the markdown content is fetched and el.outerHTML is set with renderMd(text) output, renderMermaidBlocks and renderKatexBlocks have already run synchronously in postProcessRenderedMessages. Any ```mermaid ``` fence or $$...$$ block inside the previewed file will be left as an inert placeholder div. This is the same limitation shared by the other async loaders, but worth noting since renderMd actively generates those placeholder elements.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
Teach
_run_journal_live_snapshot()to reconstructartifact_referenceandstate_savedjournal events intoartifacts[]andside_effects[]on the anchor activity scene, closing the consumer-side gap after #6204 and #6207.Changes
artifacts: list[dict] = []andside_effects: list[dict] = []alongside existing accumulators.artifact_referenceevents (extractingkind,path,event_id) andstate_savedevents (extractingkind,name,action,event_id) in the journal event loop. Malformed/empty payloads are safely ignored.artifactsandside_effectsin the returnedanchor_activity_scenedict.Acceptance Criteria
isinstance(payload, dict) and payload).artifacts/side_effectsfields only).Refs: #6212, #6204, #6207, #3400