Skip to content

feat(canvas): make Live Canvas live — publish cell changes on a channel (#17020) - #17697

Merged
mrveiss merged 3 commits into
mainfrom
issue-17020-canvas-live-stream
Sep 28, 2026
Merged

mrveiss merged 3 commits into
mainfrom
issue-17020-canvas-live-stream

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

"Live Canvas — agent-driven visual canvas workspace" (#7425) loaded once and received nothing. Measured by call site rather than by name:

useCanvasWebSocket.ts:15        opened /api/canvas/{id}/ws -- api/canvas.py has
                                five REST routes and NO websocket
get_canvas_cell_event     :325  ZERO callers (one occurrence: its own def)
register_canvas_streaming_task  ZERO callers
unregister_...                  ZERO callers
CanvasView.vue            :83   onMounted(loadCanvas), no polling

A registration is not a call. _format_canvas_cell sits in MESSAGE_TYPE_FORMATTERS under the key "canvas_cell", so every reader who searched for the name found it — in a dispatch map — and stopped. That is why this change is verified by call sites, and why the guard asserts call sites rather than presence.

Both of the issue's options were unavailable, and the doctrine decided the third. AC1 said to wire the client to "the endpoint that actually carries canvas events": there was none. Its alternative — remove as superseded by the shared bus — was equally false, since the bus had no canvas_cell publisher and no other consumer. The approved direction was "a WebSocket route on api/canvas.py plus the agent-side publisher", and EVENT_STATE_DOCTRINE forbids the first half of that in two places:

5. Channels, not routes. New event types extend the channel grammar in live_event_manager.py. They do not add a WebSocket endpoint.
1. One bus. A second bus is never the answer to a delivery problem — three parallel systems is the state this codebase was already in once.

Its anti-goals do grant own sockets to binary and high-volume streams — VNC framebuffers, audio, PTY bytes, log tails — and a JSON cell delta is not in that carve-out. So serving the route the client asked for would have built the third transport my own #17643 had just finished documenting. I built the channel instead and did not serve the socket. The guard fails if anyone adds one.

What Changed

live_event_manager.py — canvas joins _VALID_PREFIXES, and the module docstring's channel list with it.

api/live_events.py — _authorize_canvas_channel, gating on canvas.user_id == caller: the same rule every REST route on the resource applies, asked rather than re-derived, so the socket and the REST surface cannot drift into disagreeing about who may see a canvas. Admins bypass, as on every other prefix. Fails closed on an unknown canvas, an unreadable store, or a raising lookup — the doctrine lists that as a rule with teeth, and it matters here because publishing on a channel makes authorization load-bearing for the first time.

api/canvas.py — the three routes that write a cell publish canvas_cell after committing. Before the commit would announce a write a rollback discards; inside the persistence step would make delivery its side effect (principle 4). The PUT upsert collects the cells it actually mutated and publishes each after the single commit.

canvas/events.py (new) — the publisher. PersistStrategy.NONE deliberately: the durable fact is the cell row, and a client that was away re-reads it with GET /api/canvas/{id}, which is what principle 2 asks. It reuses get_canvas_cell_event's payload shape rather than re-specifying one, so the field names the frontend already parses stay the contract. It never raises into its caller — the write has committed by then, and a delivery failure must not turn a successful save into an error.

useCanvasWebSocket.ts — subscribes to canvas:{id} on the shared bus. The message handling is unchanged, conflict branch included — it was written for exactly this event and had simply never been reached. It unsubscribes its own channel on unmount rather than disconnecting a socket other live views share.

The file-size rule made me split, not raise

api/canvas.py was grandfathered at 637 lines in a shrink-only ratchet, and the call sites pushed it to 647. Rich-payload validation — 63 lines, pure, no router or session — moved to canvas/rich_payload.py. The file is now 584, under MAX_LINES, so its KNOWN_LARGE and RATCHET_BASELINE entries are deleted, per the tool's own message:

an entry naming a compliant file exempts nothing while looking authoritative

So api/canvas.py comes off the grandfather list entirely, which is what a shrink-only baseline is for.

Verification

$ python3 -m pytest repo_tests/canvas_live_channel_17020_test.py -q        9 passed
$ python3 -m pytest repo_tests/python_file_size_ratchet_test.py -q        31 passed
$ npx vitest run src/composables/__tests__/useCanvasWebSocket.test.ts     7 passed
$ npm run type-check                                                      exit 0
$ npx eslint <the 2 changed/added frontend files>                         exit 0

Pre-push selected and ran autobot-backend/api/canvas_channel_authz_17020_test.py itself — 8 authorization tests, including the non-owner refusal, the unknown canvas, the raising lookup, and the username fallback — and reported [pre-push OK] pytest: all relevant tests pass.

Guard mutation-tested six ways, each applied then reverted:

remove the publish from add_cell            CAUGHT
publish BEFORE the commit in add_cell       CAUGHT
serve a @router.websocket on the canvas router  CAUGHT
drop canvas from _VALID_PREFIXES            CAUGHT
make the authorizer fail OPEN               CAUGHT
unhook the authorizer from the dispatcher   CAUGHT

The last two are the ones I would want a reviewer to check by hand anyway: a channel that is authorized by a function nobody calls is the same defect class as the socket nobody served.

What this does NOT deliver

Agent delta streaming still has no producer. Nothing writes cell content except those three REST routes, so seq carries the cell's version and delta carries the full content rather than an incremental chunk. What lands here is every real cell change reaching every subscriber — which is the doctrine's "does a second connected client see the same thing as the first". An agent streaming path publishes to the same channel when it is built, with no new transport and no client change.

The three unreachable streaming helpers (register_canvas_streaming_task, its unregister pair) are left in place and undeleted — they are the cancellation half of that future producer, and "cleanup means finishing the work".

Not claimed: no host verification, no browser observation. Two-tab live behaviour rests on the unit tests, the authz tests, and the guard.

Closes #17020
Refs #7425
Refs #17009

Model Used

Claude Opus 5 (1M context)

Summary by CodeRabbit

  • New Features
    • Canvas cell changes appear in real time after cells are saved, created or moved through a transition.
  • Bug Fixes
    • Canvas live updates are restricted to authorised viewers; access is denied when canvas ownership cannot be verified.
    • Invalid chart and code content is rejected, and executable content is disabled.
    • Adding cells or dashboard widgets in quick succession no longer risks assigning duplicate IDs.

…el (#17020)

"Live Canvas — agent-driven visual canvas workspace" (#7425) loaded once and
received nothing. Measured by call site, not by name:

  useCanvasWebSocket.ts:15        opened /api/canvas/{id}/ws -- api/canvas.py
                                  has five REST routes and no websocket
  get_canvas_cell_event     :325  ZERO callers (one occurrence: its own def)
  register_canvas_streaming_task  ZERO callers
  unregister_...                  ZERO callers
  CanvasView.vue            :83   onMounted(loadCanvas), no polling

A REGISTRATION IS NOT A CALL. `_format_canvas_cell` sits in
MESSAGE_TYPE_FORMATTERS under the key "canvas_cell", so every reader who
checked for the name found it, in a dispatch map, and stopped. That is why this
change is verified by call sites and why the guard asserts them.

CHANNELS, NOT ROUTES. The issue's criterion said to wire the client to "the
endpoint that actually carries canvas events"; there was none, and the
alternative -- remove it as superseded by the shared bus -- was also false,
since the bus had no canvas_cell publisher either. Serving the bespoke socket
would have made a third parallel delivery system, which EVENT_STATE_DOCTRINE
principle 1 names as a state this codebase has already been in, and principle 5
forbids directly: new event types extend the channel grammar, they do not add
an endpoint. The doctrine's anti-goals do give binary and high-volume streams
their own sockets -- VNC, audio, PTY, log tails -- and a JSON cell delta is not
in that carve-out.

So: `canvas` joins _VALID_PREFIXES, `_authorize_canvas_channel` gates it on the
same owner check every REST route on the resource applies, and the three routes
that write a cell publish canvas_cell after committing. Publishing before the
commit would announce a write a rollback discards; publishing inside the
persistence step would make delivery its side effect (principle 4).
PersistStrategy.NONE deliberately -- the durable fact is the cell row, and a
client that was away re-reads it with GET (principle 2).

Agent DELTA streaming still has no producer. Nothing writes cell content except
those three REST routes, so `seq`/`delta` carry a version and the full content
rather than an incremental chunk. What this delivers is every real cell change
reaching every subscriber, which is what "does a second connected client see
the same thing as the first" asks. An agent streaming path publishes to the
same channel when it exists, with no new transport.

api/canvas.py was grandfathered at 637 lines in a shrink-only ratchet and the
call sites pushed it over, so rich-payload validation is extracted to
canvas/rich_payload.py -- split rather than raise, per the rule. The file is now
584 lines, under MAX_LINES, so its KNOWN_LARGE and RATCHET_BASELINE entries are
DELETED: the tool's own message says an entry naming a compliant file exempts
nothing while looking authoritative.

The consumer's message handling is unchanged, conflict branch included. It was
written for exactly this event and had simply never been reached.

Guard mutation-tested, six ways: removing a publish, publishing before the
commit, serving a @router.websocket on the canvas router, dropping the canvas
prefix, making the authorizer fail open, and unhooking it from the dispatcher
are each CAUGHT.

Closes #17020
Refs #7425
Refs #17009
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

The backend publishes committed canvas-cell changes through the shared event bus. Canvas channels use owner-based authorization, and the frontend subscribes to those channels instead of opening a dedicated WebSocket. Canvas cells and dashboard widgets now use UUID-based identifiers.

Changes

Canvas live updates

Layer / File(s) Summary
Canvas event channel and publisher
autobot-backend/canvas/events.py, autobot-backend/live_event_manager.py, repo_tests/canvas_live_channel_17020_test.py
The backend defines the canvas channel and event payload, publishes with PersistStrategy.NONE, and accepts the canvas channel prefix.
Canvas API validation and post-commit publication
autobot-backend/canvas/rich_payload.py, autobot-backend/api/canvas.py, repo_tests/canvas_live_channel_17020_test.py
The API uses the shared rich-payload validator. Autosave, cell creation, and cell transitions publish affected cells after the database commit. AST tests check route publication ordering and wiring.
Canvas channel authorization
autobot-backend/api/live_events.py, autobot-backend/api/canvas_channel_authz_17020_test.py, repo_tests/canvas_live_channel_17020_test.py
Admins can access canvas channels. Other callers must match the canvas owner; missing identity or canvas records, empty IDs, and lookup errors deny access.
Frontend canvas event subscription
autobot-frontend/src/composables/useCanvasWebSocket.ts, autobot-frontend/src/composables/__tests__/useCanvasWebSocket.test.ts
The composable subscribes to canvas:{canvasId}, processes canvas_cell events, and unsubscribes without disconnecting the shared socket. Tests cover event filtering, store updates, conflicts, and unsubscription.

Unique frontend identifiers

Layer / File(s) Summary
UUID-based cell and widget identifiers
autobot-frontend/src/stores/useCanvasStore.ts, autobot-frontend/src/stores/__tests__/useCanvasStore.test.ts, autobot-frontend/src/views/CustomDashboard.vue
Canvas cells and dashboard widgets use crypto.randomUUID() instead of timestamp-based IDs. Tests check that canvas cell IDs remain unique and that deleting one cell preserves another.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🔵 Low · up to 33154

Other subscribed canvas views can show stale cell order or rendering after an autosave until they reload. Autosave events also repeat sequence values, though the current frontend does not use them to order updates. These are bounded live-sync issues with a reload workaround.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 33154

Authenticated users receive an ownership check before joining a canvas channel. When WebSocket authentication is disabled, however, the shared authorization path can admit a subscriber without applying that check. The exposure depends on deployment configuration and knowledge of a canvas identifier.

Retained concerns

  • Medium · security · inferred: If WebSocket authentication is disabled, a connection without an identity can subscribe to a known canvas channel without passing the new canvas-owner check and receive published cell content.
Security review details

Security Blast Radius

  • inferred — The conditional anonymous path exposes cell content for canvas identifiers a subscriber knows; the evidence does not establish identifier enumeration or that authentication is disabled in production.

Security Findings and Attack Paths

  • inferred — With WebSocket authentication disabled, an unauthenticated client can request a syntactically valid canvas channel: the generic missing-payload return precedes the owner check, and subsequent cell publications carry content on that channel.

Trust Boundaries and Controls

  • observed — When authentication is required, the WebSocket entrypoint rejects a missing identity. For authenticated non-admin subscribers, the canvas-specific check compares the stored owner with the caller before subscription; channel syntax alone is not authorization.

Resilience and Maintainability Implications

  • inferred — Authorization occurs when a socket subscribes, not on each publication. Disconnect and explicit unsubscribe remove membership, but a continuously connected subscriber would retain it if canvas ownership changed; a reachable ownership-change path was not established.

Hardening Proposals

  • proposed — Make canvas-channel authorization explicitly require an identity or an equally restrictive policy even when general WebSocket authentication is disabled, rather than inheriting the generic anonymous early return.
🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Issue #17020 requires the canvas and terminal clients to use served, authenticated routes or to be retired when superseded. The PR moves the canvas client to the authenticated shared canvas:{id} eve… Retire Terminal.vue and its story after recording proof that SSHTerminal.vue or BaseXTerminal.vue supersedes it, or connect it to an authenticated served terminal route. Add a test that pins the terminal client to that route.
Out of Scope Changes check ⚠️ Warning The canvas event-bus, authorisation, publication, frontend subscription, and related tests support issue #17020. The rich-payload extraction and validation changes do not implement a live-client or se… Remove the rich-payload and dashboard widget ID changes from this PR, or link them to separate directly relevant issues.
Docstring Coverage ⚠️ Warning Docstring coverage is 53.66% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 11 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: publishing Live Canvas cell updates through a channel. It is specific and related to the changeset.
Full details: Linked Issues check

Explanation

Issue #17020 requires the canvas and terminal clients to use served, authenticated routes or to be retired when superseded. The PR moves the canvas client to the authenticated shared canvas:{id} event bus and adds canvas wiring tests. The PR does not change or retire Terminal.vue or its story. No test pins the terminal client to /api/terminal/ws/{session_id} or /ws/ssh/{host_id}. The terminal requirement and the corresponding route test remain unmet.

Full details: Out of Scope Changes check

Explanation

The canvas event-bus, authorisation, publication, frontend subscription, and related tests support issue #17020. The rich-payload extraction and validation changes do not implement a live-client or served-route objective. The dashboard widget ID change is also unrelated to issue #17020. These changes are outside the linked issue scope.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: eeb52a4b-6186-44a2-b830-42f33669ea4d

📥 Commits

Reviewing files that changed from the base of the PR and between 290fae3 and d7bff50.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (9)
  • autobot-backend/api/canvas.py
  • autobot-backend/api/canvas_channel_authz_17020_test.py
  • autobot-backend/api/live_events.py
  • autobot-backend/canvas/events.py
  • autobot-backend/canvas/rich_payload.py
  • autobot-backend/live_event_manager.py
  • autobot-frontend/src/composables/__tests__/useCanvasWebSocket.test.ts
  • autobot-frontend/src/composables/useCanvasWebSocket.ts
  • repo_tests/canvas_live_channel_17020_test.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment on lines +205 to +212
touched.append(cell)

await session.commit()
_log_metric("canvas.autosave.success", canvas_id=str(canvas_id))

for cell in touched:
await _publish_cell(canvas_id, cell)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

rg -n 'get_canvas_cell_event|canvas_cell|\\.seq\\b|seq:.*version' autobot-frontend/src autobot-backend/canvas autobot-backend/api/websockets.py | head -135
sed -n '39,65p' autobot-frontend/src/stores/useCanvasStore.ts
sed -n '85,103p' autobot-backend/canvas/events.py

Repository: mrveiss/AutoBot-AI

Length of output: 4299


🏁 Script executed:

#!/bin/bash
set -e
sed -n '1,90p' autobot-frontend/src/composables/useCanvasWebSocket.ts
sed -n '1,90p' autobot-frontend/src/types/canvas.ts
rg -n -C 3 '\bseq\b|upsertStreamCell|CanvasWsMessage' autobot-frontend/src --glob '!**/node_modules/**'
sed -n '315,350p' autobot-backend/api/websockets.py
sed -n '80,106p' autobot-backend/canvas/events.py
sed -n '175,216p' autobot-backend/api/canvas.py

Repository: mrveiss/AutoBot-AI

Length of output: 31528


🏁 Script executed:

#!/bin/bash
set -e
sed -n '35,145p' autobot-frontend/src/stores/useCanvasStore.ts
sed -n '95,145p' autobot-frontend/src/components/canvas/CanvasPanel.vue
rg -n -C 4 'pausedAgentSeq|triggerConflict|resolveConflict|conflict' autobot-frontend/src/stores autobot-frontend/src/components/canvas autobot-frontend/src/composables --glob '!**/node_modules/**'

Repository: mrveiss/AutoBot-AI

Length of output: 27299


Increment cell.version before publishing autosave events.

cell_payload maps cell.version to seq and documents it as the sequence for client ordering. Autosave changes the cell but leaves cell.version unchanged, so repeated updates use the same seq.

The current frontend stores seq and passes it to conflict state, but it does not compare or use it to reject events. This is therefore not a major stale-update failure in the current consumer. It still violates the ordering contract and makes sequence-based consumers unreliable.

🐛 Suggested fix
                     cell.position = item.position
                     cell.content = item.content
                     cell.type = item.type
+                    cell.version = cell.version + 1
                     cell.updated_at = now

Comment on lines +263 to +267
async with get_async_session_factory()() as session:
canvas = (await session.execute(select(Canvas).where(Canvas.id == ident))).scalar_one_or_none()
if canvas is None:
return False
return str(canvas.user_id) == user_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '232,305p' autobot-backend/api/live_events.py
rg -n 'class Canvas\\b|id =.*UUID|id:.*UUID|_authorize_channel\\(' autobot-backend | head -65

Repository: mrveiss/AutoBot-AI

Length of output: 3841


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- Canvas model declarations ---'
rg -n -F 'class Canvas' .
rg -n 'Canvas\.id|id[[:space:]]*=[[:space:]]*.*UUID|UUID\(' autobot-backend --glob '*.py' | head -120
printf '%s\n' '--- authorization callers and subscription dispatch ---'
rg -n '_authorize_channel|subscribe|canvas:' autobot-backend/api/live_events.py
printf '%s\n' '--- database/backend configuration ---'
rg -n 'asyncpg|postgres|postgresql|DATABASE_URL|create_async_engine|dialect' autobot-backend --glob '*.py' --glob '*.env*' --glob '*.toml' --glob '*.yaml' --glob '*.yml' | head -160

Repository: mrveiss/AutoBot-AI

Length of output: 36803


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- Canvas model ---'
sed -n '1,75p' autobot-backend/canvas/models.py
printf '%s\n' '--- subscription dispatch ---'
sed -n '330,382p' autobot-backend/api/live_events.py
printf '%s\n' '--- async database URL selection ---'
sed -n '1,75p' autobot-backend/migrations/db_url.py
printf '%s\n' '--- dependency declarations ---'
rg -n 'asyncpg|SQLAlchemy|sqlalchemy' pyproject.toml autobot-backend/pyproject.toml requirements*.txt autobot-backend/requirements*.txt 2>/dev/null | head -80
printf '%s\n' '--- installed library contract sources, if available ---'
python3 - <<'PY'
import importlib.util
for name in ("asyncpg", "sqlalchemy"):
    spec = importlib.util.find_spec(name)
    print(name, spec.origin if spec else "not installed")
PY

Repository: mrveiss/AutoBot-AI

Length of output: 7186


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- runtime session factory ---'
rg -n -C 5 'def get_async_session_factory|get_async_session_factory|create_async_engine|async_url|asyncpg' autobot-backend/user_management autobot-backend --glob '*.py' | head -180
printf '%s\n' '--- exact authorization lines ---'
nl -ba autobot-backend/api/live_events.py | sed -n '238,301p;360,375p'

Repository: mrveiss/AutoBot-AI

Length of output: 20182


🌐 Web query:

asyncpg PostgreSQL UUID query argument string expected uuid official documentation

💡 Result:

`asyncpg` expects PostgreSQL `uuid` parameters as Python `uuid.UUID` objects—not plain strings—according to its type-mapping documentation. ([github.com](https://github.com/MagicStack/asyncpg/blob/master/docs/usage.rst?utm_source=openai))

Convert the string before passing it:

```python
from uuid import UUID

await conn.fetchrow(
    "SELECT * FROM users WHERE id = $1",
    UUID(user_id),
)
```

If you need to pass a string, cast the parameter in SQL so PostgreSQL treats it as a UUID:

```python
await conn.fetchrow(
    "SELECT * FROM users WHERE id = $1::uuid",
    user_id,
)
```

PostgreSQL accepts UUIDs in standard hyphenated form and several alternatives, but invalid UUID text will still fail to parse. ([postgresql.org](https://www.postgresql.org/docs/current/datatype-uuid.html?utm_source=openai))

Citations:

- 1: https://github.com/MagicStack/asyncpg/blob/master/docs/usage.rst?utm_source=openai
- 2: https://www.postgresql.org/docs/current/datatype-uuid.html?utm_source=openai

🏁 Script executed:

#!/bin/bash
set -u
nl -ba autobot-backend/api/live_events.py | sed -n '1,32p'

Repository: mrveiss/AutoBot-AI

Length of output: 2039


🏁 Script executed:

#!/bin/bash
set -u
nl -ba autobot-backend/api/live_events.py | sed -n '33,58p'

Repository: mrveiss/AutoBot-AI

Length of output: 1229


Validate the canvas ID before the lookup.

A non-admin authenticated client can submit a non-UUID canvas: channel. Canvas.id is a PostgreSQL UUID column, and asyncpg expects a Python uuid.UUID value. The lookup can therefore raise, and the broad handler writes a full traceback for each request before denying access.

Suggested fix
 import asyncio
 import json
+import uuid
 
@@
     if not ident:
         return False
+    try:
+        canvas_id = uuid.UUID(ident)
+    except ValueError:
+        return False
     try:
@@
-            canvas = (await session.execute(select(Canvas).where(Canvas.id == ident))).scalar_one_or_none()
+            canvas = (await session.execute(select(Canvas).where(Canvas.id == canvas_id))).scalar_one_or_none()

Comment on lines +77 to +82
except Exception:
logger.warning(
"canvas_cell publish failed for canvas %s cell %s",
canvas_id,
getattr(cell, "id", "?"),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '48,105p' autobot-backend/canvas/events.py
rg -n 'publish failed|exc_info=True|logger.exception' autobot-backend/canvas autobot-backend/api/live_events.py | head -70

Repository: mrveiss/AutoBot-AI

Length of output: 3006


Log exception details when publishing fails.

The except block suppresses the publish exception, but logger.warning records only the canvas and cell identifiers. Add exc_info=True so repeated bus failures include their cause and traceback.

Suggested fix
         logger.warning(
             "canvas_cell publish failed for canvas %s cell %s",
             canvas_id,
             getattr(cell, "id", "?"),
+            exc_info=True,
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception:
logger.warning(
"canvas_cell publish failed for canvas %s cell %s",
canvas_id,
getattr(cell, "id", "?"),
)
except Exception:
logger.warning(
"canvas_cell publish failed for canvas %s cell %s",
canvas_id,
getattr(cell, "id", "?"),
exc_info=True,
)
🧰 Tools
🪛 Ruff (0.16.6)

[warning] 77-77: Do not catch blind exception: Exception

(BLE001)

Comment on lines +96 to +102
return get_canvas_cell_event(
cell_id=str(cell.id),
seq=getattr(cell, "version", 0),
delta=getattr(cell, "content", None) or "",
state=getattr(cell, "state", ""),
canvas_id=str(canvas_id),
)["payload"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '35,95p' autobot-frontend/src/stores/useCanvasStore.ts
sed -n '175,217p;298,334p' autobot-backend/api/canvas.py
sed -n '85,105p' autobot-backend/canvas/events.py

Repository: mrveiss/AutoBot-AI

Length of output: 6047


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- relevant diff ---'
git diff --unified=40 290fae38c060e835fd3d8bd6a6bdfdc4a8381272 d7bff5075d724e11e021238dc26d6f7b04c9ff15 -- autobot-backend/canvas/events.py autobot-backend/api/canvas.py autobot-backend/api/websockets.py autobot-frontend/src/composables/useCanvasWebSocket.ts autobot-frontend/src/stores/useCanvasStore.ts autobot-frontend/src/types/canvas.ts
printf '%s\n' '--- event handler and store ---'
sed -n '1,110p' autobot-frontend/src/composables/useCanvasWebSocket.ts
sed -n '1,90p' autobot-frontend/src/stores/useCanvasStore.ts
sed -n '1,90p' autobot-frontend/src/types/canvas.ts
printf '%s\n' '--- canvas event implementations and uses ---'
sed -n '1,180p' autobot-backend/canvas/events.py
rg -n -C 5 'canvas_cell|get_canvas_cell_event|upsertStreamCell|publish_cell|_publish_cell' autobot-backend autobot-frontend --glob '!**/node_modules/**'

Repository: mrveiss/AutoBot-AI

Length of output: 42328


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- publisher ---'
sed -n '55,110p' autobot-backend/canvas/events.py
printf '%s\n' '--- live handler ---'
sed -n '1,85p' autobot-frontend/src/composables/useCanvasWebSocket.ts
printf '%s\n' '--- event contract ---'
sed -n '315,355p' autobot-backend/api/websockets.py
printf '%s\n' '--- store and types ---'
sed -n '1,75p' autobot-frontend/src/stores/useCanvasStore.ts
sed -n '35,70p' autobot-frontend/src/types/canvas.ts
printf '%s\n' '--- producer bindings ---'
rg -n -C 8 'get_canvas_cell_event|publish_event\\(|upsertStreamCell|canvas_cell' autobot-backend autobot-frontend --glob '!**/node_modules/**' --glob '!**/*.map'
printf '%s\n' '--- focused tests ---'
rg -n -C 12 'upsertStreamCell|delta|seq|content' autobot-frontend/src/stores autobot-frontend/src/composables/__tests__ autobot-backend/tests --glob '*canvas*' --glob '*websocket*' --glob '*.test.*' --glob '*.py'

Repository: mrveiss/AutoBot-AI

Length of output: 10017


Preserve snapshot updates separately from streaming deltas.

Autosave and transition edits publish the complete persisted cell.content as delta. The frontend appends delta to an existing cell. For example, persisted content A can become AA after the live event. seq does not prevent this because the store assigns it without comparing or using it for replacement.

Mark these events as snapshots and replace existing content for snapshot events. Keep append behaviour for the existing streaming-delta contract.

Suggested fix
diff --git a/autobot-backend/canvas/events.py b/autobot-backend/canvas/events.py
@@
-    return get_canvas_cell_event(
+    payload = get_canvas_cell_event(
         cell_id=str(cell.id),
         seq=getattr(cell, "version", 0),
         delta=getattr(cell, "content", None) or "",
         state=getattr(cell, "state", ""),
         canvas_id=str(canvas_id),
     )["payload"]
+    payload["updateMode"] = "snapshot"
+    return payload
diff --git a/autobot-frontend/src/types/canvas.ts b/autobot-frontend/src/types/canvas.ts
@@
   delta: string
   state: CellStreamState
+  updateMode?: 'delta' | 'snapshot'
 }
diff --git a/autobot-frontend/src/stores/useCanvasStore.ts b/autobot-frontend/src/stores/useCanvasStore.ts
@@
-  function upsertStreamCell(msg: Pick<CanvasWsMessage, 'cellId' | 'seq' | 'delta' | 'state'>) {
+  function upsertStreamCell(msg: Pick<CanvasWsMessage, 'cellId' | 'seq' | 'delta' | 'state' | 'updateMode'>) {
     const existing = cells.value.find(c => c.id === msg.cellId)
     if (existing) {
-      existing.content += msg.delta
+      existing.content = msg.updateMode === 'snapshot'
+        ? msg.delta
+        : existing.content + msg.delta
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return get_canvas_cell_event(
cell_id=str(cell.id),
seq=getattr(cell, "version", 0),
delta=getattr(cell, "content", None) or "",
state=getattr(cell, "state", ""),
canvas_id=str(canvas_id),
)["payload"]
payload = get_canvas_cell_event(
cell_id=str(cell.id),
seq=getattr(cell, "version", 0),
delta=getattr(cell, "content", None) or "",
state=getattr(cell, "state", ""),
canvas_id=str(canvas_id),
)["payload"]
payload["updateMode"] = "snapshot"
return payload

Comment on lines +136 to +146
def test_the_mutating_routes_are_the_ones_this_file_names() -> None:
"""Discovery must agree with the list, so a new writing route fails here."""
handlers = route_handlers(_module(_CANVAS_API))
writing = {name for name, method in handlers.items() if method in {"put", "post", "patch"}}
# `export_canvas` is a POST that writes nothing.
writing.discard("export_canvas")
assert writing == _MUTATING_ROUTES, (
f"the set of writing canvas routes changed: {sorted(writing)} against "
f"{sorted(_MUTATING_ROUTES)}. A new one must publish too -- add it here "
f"and give it a publish, rather than widening this set alone"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '31,90p;130,198p' repo_tests/canvas_live_channel_17020_test.py

Repository: mrveiss/AutoBot-AI

Length of output: 5592


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- test file outline and relevant references ---'
rg -n "route_handlers|websocket|contrast|synthetic|fixture|REACH|mutating|_MUTATING_ROUTES" repo_tests/canvas_live_channel_17020_test.py repo_tests || true
printf '%s\n' '--- canvas route declarations ---'
rg -n -C 2 "@router|APIRouter|websocket" autobot-backend/api/canvas.py
printf '%s\n' '--- changed-file diff against supplied merge base ---'
git diff --unified=40 290fae38c060e835fd3d8bd6a6bdfdc4a8381272 d7bff5075d724e11e021238dc26d6f7b04c9ff15 -- repo_tests/canvas_live_channel_17020_test.py

Repository: mrveiss/AutoBot-AI

Length of output: 41770


🏁 Script executed:

set -e
rg -n -C 2 "@router|APIRouter|websocket" autobot-backend/api/canvas.py
rg -n -C 2 "route_handlers|websocket|contrast|synthetic|fixture|REACH|_MUTATING_ROUTES" repo_tests/canvas_live_channel_17020_test.py
git diff --unified=20 290fae38c060e835fd3d8bd6a6bdfdc4a8381272 d7bff5075d724e11e021238dc26d6f7b04c9ff15 -- repo_tests/canvas_live_channel_17020_test.py

Repository: mrveiss/AutoBot-AI

Length of output: 13998


Add a reach floor to the WebSocket-route guard.

route_handlers can return an empty mapping when the router name or decorator shape changes. The WebSocket guard then passes because it finds no "websocket" method. Add the five-route reach floor to this guard only. The mutating-route equality guard already rejects empty discovery because _MUTATING_ROUTES contains three handlers.

Suggested fix
 def test_the_canvas_router_grows_no_websocket_route() -> None:
     """Principle 5: channels, not routes. The unserved socket must not be served."""
-    methods = set(route_handlers(_module(_CANVAS_API)).values())
+    handlers = route_handlers(_module(_CANVAS_API))
+    assert len(handlers) >= 5, (
+        f"only {len(handlers)} canvas routes were discovered; expected at least 5"
+    )
+    methods = set(handlers.values())
     assert "websocket" not in methods, (

…branch overwrote the cell it protects (#17020)

The conflict test added in this PR failed in CI and passed locally. The test was
right; `addCell` was wrong.

    id: `cell-${Date.now()}`

Millisecond resolution, and every operation in this store addresses a cell BY
id -- `deleteCell`, `moveCell`, `upsertStreamCell`, and the template's `:key`.
Two cells created in the same tick shared one.

That is what broke the conflict branch specifically. It adds an agent cell and
then addresses "the last one":

    store.addCell('agent')
    const newId = store.cells[store.cells.length - 1].id
    store.upsertStreamCell({ ...msg, cellId: newId })

With a duplicate id, `newId` equals the conflicted cell's id, so the delta
landed straight back on the cell the user was resolving -- the one thing that
branch exists to prevent.

WHY IT WAS NEVER CAUGHT BEFORE. That branch had never executed. Making it run
for the first time is what #17020 is for, and a latent collision in id
generation is precisely the kind of defect only a first execution can reveal.
Better here than after merge.

Reproduced deterministically with a frozen clock rather than by a fast loop --
a loop only collides when the machine is quick enough, which is why it passed
on mine and failed in CI. The three new store tests use `vi.useFakeTimers()`,
and one asserts the CONSEQUENCE (`deleteCell` removes the cell asked for, not
whichever `find` reaches first) so it stays meaningful if the id scheme changes
again.

`crypto.randomUUID()` matches the five existing call sites, including the same
shape at `useAppStore.ts:195`.

FIXED AS A CLASS, NOT AN INSTANCE. `views/CustomDashboard.vue:516` had the
identical defect -- `id: \`widget-${Date.now()}\`` -- and dashboards address a
widget by id for layout, removal and `:key`. Both are fixed here rather than
one fixed and one left for whichever PR next trips over it. A sweep for
`id: \`...${Date.now()}\`` across the frontend now returns nothing.

Mutation-tested: reverting either id to `Date.now()` is CAUGHT -- by all three
store tests and by the CI conflict test.
@mrveiss

mrveiss commented Sep 28, 2026

Copy link
Copy Markdown
Owner Author

Follow-up commit 7fdd02b9a8: the conflict test that failed in CI was right, and addCell was wrong.

The defect

id: `cell-${Date.now()}`      // useCanvasStore.addCell

Millisecond resolution, and every operation in that store addresses a cell by id — deleteCell, moveCell, upsertStreamCell, and the template's :key. Two cells created in the same tick shared one.

That is what broke the conflict branch specifically. It adds an agent cell and then addresses "the last one":

store.addCell('agent')
const newId = store.cells[store.cells.length - 1].id
store.upsertStreamCell({ ...msg, cellId: newId })

With a duplicate id, newId is the conflicted cell's id — so the delta landed straight back on the cell the user was resolving. The one thing that branch exists to prevent.

Why nothing caught it before

That branch had never executed. It was written for an event delivered over a socket the backend never served, so it had never been wrong — only unreached. Making it run for the first time is what this PR is for, and a latent collision in id generation is exactly the class of defect that only a first execution can reveal.

That is a better argument for this PR existing than the feature is.

Reproduced deterministically, not by luck

It passed on my machine and failed in CI, which is the signature of a timing-dependent test. A fast loop only collides when the machine is quick enough, so the three new store tests freeze the clock with vi.useFakeTimers() instead. One of them asserts the consequence rather than the id — deleteCell removes the cell asked for, not whichever find reaches first — so it stays meaningful if the id scheme changes again.

crypto.randomUUID() matches the five existing call sites, including the identical shape at useAppStore.ts:195.

Fixed as a class, not an instance

views/CustomDashboard.vue:516 had the same defect — id: + backtick-widget-${Date.now()} — and a dashboard addresses a widget by id for layout, removal and :key. Both are fixed in this commit rather than one fixed and one left for whichever PR next trips over it, per the one-defect-one-fix rule. A sweep for that shape across the frontend now returns nothing.

Verification

$ npx vitest run src/stores src/composables/__tests__/useCanvasWebSocket.test.ts
Test Files  7 passed (7)      Tests  82 passed (82)

$ npm run type-check      exit 0
$ npx eslint <3 changed files>  exit 0

Mutation-tested. Reverting either id back to Date.now():

store regression tests   CAUGHT (all three)
the CI conflict test     CAUGHT

Both caught, and the conflict test now reproduces the CI failure locally too — the two addCell calls land in the same millisecond on this machine as well.

One process note

This went onto this branch rather than a new one. The failing test is in this PR and this PR is what it blocks, so a separate branch would have left #17697 red until a second PR merged — and the standing rule is to fix it in the same PR rather than open a second full CI suite. Flagging it because I was pointed at a separate branch and deliberately did not take that route.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Include autosave position and cell type in canvas_cell updates. · events.py:85-102

autobot-backend/canvas/events.py:85-102
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Include autosave position and cell type in canvas_cell updates.

When put_canvas changes a cell, _publish_cell emits only cellId, seq, delta, and state. useCanvasWebSocket forwards that payload to upsertStreamCell, which does not update the cell type or its position in the subscribed store. A second canvas view can therefore keep stale order and rendering until it reloads.

Extend the shared payload and apply the fields in the store. Use cellType, because type is reserved for the event envelope.

Suggested fix
diff --git a/autobot-backend/api/websockets.py b/autobot-backend/api/websockets.py
@@
-def get_canvas_cell_event(cell_id: str, seq: int, delta: str, state: str, canvas_id: Optional[str] = None) -> dict:
+def get_canvas_cell_event(
+    cell_id: str,
+    seq: int,
+    delta: str,
+    state: str,
+    canvas_id: Optional[str] = None,
+    position: Optional[int] = None,
+    cell_type: Optional[str] = None,
+) -> dict:
@@
             "seq": seq,
             "delta": delta,
             "state": state,
+            "position": position,
+            "cellType": cell_type,
         },
     }
diff --git a/autobot-backend/canvas/events.py b/autobot-backend/canvas/events.py
@@
         delta=getattr(cell, "content", None) or "",
         state=getattr(cell, "state", ""),
         canvas_id=str(canvas_id),
+        position=getattr(cell, "position", 0),
+        cell_type=getattr(cell, "type", ""),
     )["payload"]
diff --git a/autobot-frontend/src/types/canvas.ts b/autobot-frontend/src/types/canvas.ts
@@
 export interface CanvasWsMessage {
   type: 'canvas_cell'
   cellId: string
   seq: number
   delta: string
   state: CellStreamState
+  position: number
+  cellType: string
 }
diff --git a/autobot-frontend/src/stores/useCanvasStore.ts b/autobot-frontend/src/stores/useCanvasStore.ts
@@
-  function upsertStreamCell(msg: Pick<CanvasWsMessage, 'cellId' | 'seq' | 'delta' | 'state'>) {
-    const existing = cells.value.find(c => c.id === msg.cellId)
+  function upsertStreamCell(msg: Pick<CanvasWsMessage, 'cellId' | 'seq' | 'delta' | 'state' | 'position' | 'cellType'>) {
+    const existingIndex = cells.value.findIndex(c => c.id === msg.cellId)
+    const existing = existingIndex >= 0 ? cells.value[existingIndex] : undefined
     if (existing) {
       existing.content += msg.delta
       existing.streamState = msg.state
       existing.seq = msg.seq
+      existing.contentType = (msg.cellType === 'text' ? 'markdown' : msg.cellType) as CanvasCell['contentType']
+      const [cell] = cells.value.splice(existingIndex, 1)
+      cells.value.splice(Math.max(0, Math.min(msg.position, cells.value.length)), 0, cell)
     } else {
-      cells.value.push({
+      const cell = {
         id: msg.cellId,
         canvasId: canvasId.value ?? '',
         owner: 'agent',
-        contentType: 'markdown',
+        contentType: (msg.cellType === 'text' ? 'markdown' : msg.cellType) as CanvasCell['contentType'],
         content: msg.delta,
         streamState: msg.state,
         seq: msg.seq,
         createdAt: new Date().toISOString(),
         updatedAt: new Date().toISOString(),
-      })
+      }
+      cells.value.splice(Math.max(0, Math.min(msg.position, cells.value.length)), 0, cell)
     }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 904dd9ec-50a8-49f2-9b5a-f23ee9eab38e

📥 Commits

Reviewing files that changed from the base of the PR and between 7fdd02b and 3315429.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (2)
  • autobot-backend/api/canvas.py
  • autobot-backend/live_event_manager.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

@mrveiss
mrveiss merged commit 0811143 into main Sep 28, 2026
82 checks passed
@mrveiss
mrveiss deleted the issue-17020-canvas-live-stream branch September 28, 2026 14:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

frontend: two WebSocket clients open endpoints the backend does not serve (canvas /api/canvas/{id}/ws, legacy Terminal.vue)

1 participant