Look around the machine before deciding to talk about it

The terminal and the canvas both needed a Chat, so they were missing from the
one screen where you are choosing which machine to work on. A draft is the
smallest thing that fixes it: an id, and the three facts behind it.

The trick is that a draft resolves to a *transient* Chat -- constructed, never
added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent`
and `agent_session.resolve` read exactly four attributes between them and none
of them queries or writes the row, so all of it works unchanged and nothing had
to learn what a draft is. Proven against a real sshd rather than a stub: a
transient chat opens and saves a project file over the same SFTP path a real one
uses, and the database stays empty throughout.

Chats are still created lazily. A draft is not a chat and never becomes one;
when the first prompt makes the real one, the shell is re-keyed into it and the
open tabs are copied across. `terminal.rekey` moves the registry key *and*
`session.chat_id`, because close_for_profile, close_for_owner and the reaper all
pop by the field -- a stale one would leave a dead session that `get` keeps
handing out. The shell is only adopted when its profile and directory match the
chat as finally resolved, since `_new_chat` settles an empty directory to the
connection's own; otherwise it is left alone rather than transplanted onto a
chat that says it runs elsewhere.

Two canvas sources are refused on a draft, by name, and one of them is a hole
rather than an inconvenience. `_load_file` authorises with
`attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
stored with `chat_id=None` -- so a draft whose chat carried no id would make that
comparison `None != None`, which is False, and open every unclaimed attachment
its owner has. `as_chat` does set an id, so it already fails; the refusal is
stated anyway, because a guarantee that lives in an id-shaped coincidence is one
the next change breaks without noticing.

Adoption needed almost no JavaScript: start_chat already answers with
HX-Redirect, so the page reloads and the canvas adopts by construction while the
terminal reconnects to the re-keyed session and replays its scrollback -- the "a
reload is indistinguishable from a second tab" property working for us. What
re-points them mid-screen is a `lembas:agent-target` event, dispatched from
`setDir` and the connection select because assigning to a hidden field's value
fires nothing on its own. Driven under a DOM stub before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 21:33:34 +02:00
parent 30ddcba787
commit a63723713f
14 changed files with 853 additions and 14 deletions
+18
View File
@@ -24,6 +24,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.api.pages import sidebar_context from lembas.api.pages import sidebar_context
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
from lembas.services import settings_store from lembas.services import settings_store
from lembas.services.agent import draft as draft_service
from lembas.services.agent import index as index_service from lembas.services.agent import index as index_service
from lembas.services.agent import jobs as jobs_service from lembas.services.agent import jobs as jobs_service
from lembas.services.agent import ssh as ssh_service from lembas.services.agent import ssh as ssh_service
@@ -276,6 +277,23 @@ def _job_chat(db: Db, user: RequiredUser, chat_id: str):
return chat, agent_session.resolve(db, chat, user) return chat, agent_session.resolve(db, chat, user)
@router.get("/api/agents/{profile_id}/draft")
async def draft_target(db: Db, user: RequiredUser, profile_id: str, dir: str = ""):
"""The id the panels should use for a chat that does not exist yet.
Hung off the profile rather than the chat for the reason `browse` is: the
caller is the *new*-chat composer, where the connection and the directory
are the things being chosen. Ownership of the profile is the whole
authorisation, as everywhere else in this module.
Deterministic, so asking twice for the same target gives the same id and
finds the shell already running there rather than opening a second one.
"""
profile = _profile(db, user, profile_id)
draft = draft_service.remember(user.id, profile.id, dir or profile.default_dir or "")
return {"id": draft.id, "dir": draft.project_dir}
@router.get("/api/chats/{chat_id}/jobs") @router.get("/api/chats/{chat_id}/jobs")
async def jobs_chip(request: Request, db: Db, user: RequiredUser, chat_id: str): async def jobs_chip(request: Request, db: Db, user: RequiredUser, chat_id: str):
"""How many jobs are running, as the chip in the composer row. """How many jobs are running, as the chip in the composer row.
+39 -1
View File
@@ -21,6 +21,7 @@ from lembas.api.deps import Db, RequiredUser
from lembas.db.models import Chat, User from lembas.db.models import Chat, User
from lembas.services import canvas as canvas_service from lembas.services import canvas as canvas_service
from lembas.services import generation as generation_service from lembas.services import generation as generation_service
from lembas.services.agent import draft as draft_service
from lembas.services.agent.base import Conflict from lembas.services.agent.base import Conflict
from lembas.services.markdown import highlight_code, render_markdown from lembas.services.markdown import highlight_code, render_markdown
from lembas.web.templating import templates from lembas.web.templating import templates
@@ -32,13 +33,38 @@ router = APIRouter(prefix="/api/chats", tags=["canvas"])
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat: def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
"""404 rather than 403 for somebody else's chat: whether it exists at all is """404 rather than 403 for somebody else's chat: whether it exists at all is
not this account's business.""" not this account's business.
A draft id resolves to a transient `Chat` -- constructed, never saved --
which is what lets the canvas work on the new-chat screen without any of the
six sources learning that drafts exist. See services/agent/draft.py.
"""
if draft_service.is_draft(chat_id):
draft = draft_service.get(chat_id, user_id)
if draft is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
return draft_service.as_chat(draft)
chat = db.get(Chat, chat_id) chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user_id: if chat is None or chat.user_id != user_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.") raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
return chat return chat
def _remember_tabs(chat: Chat, state: dict) -> bool:
"""Put the tab strip back where it came from. True when it was a draft.
A draft's tabs live in the registry rather than on a row, so the two write
paths below fork here rather than each remembering to check.
"""
if not draft_service.is_draft(chat.id):
return False
draft = draft_service.get(chat.id, chat.user_id)
if draft is not None:
draft.canvas_json = dict(state or {})
return True
async def _panel( async def _panel(
request: Request, request: Request,
db: DBSession, db: DBSession,
@@ -121,6 +147,16 @@ async def open_tab(
""" """
chat = _owned_chat(db, chat_id, user.id) chat = _owned_chat(db, chat_id, user.id)
# Two of the six sources need a real row behind them, and one of those is a
# hole rather than an inconvenience -- see draft.SOURCES_NEEDING_A_CHAT.
# Refused by source name, here, rather than left to fall out of an id
# comparison somewhere further in.
if draft_service.is_draft(chat.id) and draft_service.refuses(key.split(":", 1)[0]):
return await _panel(
request, db, user, chat,
message="That can only be opened once this chat exists. Send a message first.",
)
try: try:
doc = await canvas_service.load(db, user, chat, key) doc = await canvas_service.load(db, user, chat, key)
except canvas_service.Refused as exc: except canvas_service.Refused as exc:
@@ -133,6 +169,7 @@ async def open_tab(
# Reassigned rather than mutated: an in-place edit of a JSON column is not # Reassigned rather than mutated: an in-place edit of a JSON column is not
# reliably detected as a change. # reliably detected as a change.
chat.canvas_json = state chat.canvas_json = state
if not _remember_tabs(chat, state):
db.commit() db.commit()
# A reply running right now holds its own snapshot, seeded when it started. # A reply running right now holds its own snapshot, seeded when it started.
@@ -151,6 +188,7 @@ async def close_tab(
): ):
chat = _owned_chat(db, chat_id, user.id) chat = _owned_chat(db, chat_id, user.id)
chat.canvas_json = canvas_service.close_tab(dict(chat.canvas_json or {}), key) chat.canvas_json = canvas_service.close_tab(dict(chat.canvas_json or {}), key)
if not _remember_tabs(chat, chat.canvas_json):
db.commit() db.commit()
live = generation_service.running_for(chat.id) live = generation_service.running_for(chat.id)
+51
View File
@@ -40,6 +40,7 @@ from lembas.services import prompts as prompts_service
from lembas.services import steps as steps_service from lembas.services import steps as steps_service
from lembas.services import tokens as tokens_service from lembas.services import tokens as tokens_service
from lembas.services import tools as tools_service from lembas.services import tools as tools_service
from lembas.services.agent import draft as draft_service
from lembas.services.agent import policy as agent_policy from lembas.services.agent import policy as agent_policy
from lembas.services.agent import terminal as terminal_service from lembas.services.agent import terminal as terminal_service
from lembas.services.markdown import escape_text, render_markdown from lembas.services.markdown import escape_text, render_markdown
@@ -81,6 +82,53 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
return chat return chat
def _adopt_draft(db: DBSession, user: User, draft_id: str, chat: Chat) -> None:
"""Hand the new-chat screen's shell and open files to the chat it became.
Between `_new_chat` and the first message deliberately: the chat has an id by
here, and `generation.ensure` below has not yet started a reply that would
read `chat.canvas_json`.
The shell is only adopted when it is a shell on the same target. `_new_chat`
settles `project_dir` last -- an empty one falls back to the connection's own
login directory -- so the comparison is against the chat as resolved, never
against what the form said. On a mismatch the session is left alone rather
than transplanted onto a chat that says it runs somewhere else; it belongs to
whatever draft it was opened under and is reaped on idle.
"""
if not draft_id or not draft_service.is_draft(draft_id):
return
draft = draft_service.get(draft_id, user.id)
if draft is None:
return
matches = (
chat.kind == KIND_AGENT
and draft.profile_id == (chat.ssh_profile_id or "")
and draft.project_dir == (chat.project_dir or "")
)
if not matches:
return
session = terminal_service.peek(draft_id)
if session is not None:
terminal_service.rekey(draft_id, chat.id)
# Only what a chat can actually reopen. A tab whose source needs a row it
# never had is dropped rather than carried across to fail on first click.
tabs = dict(draft.canvas_json or {})
kept = [
tab
for tab in tabs.get("tabs") or []
if not draft_service.refuses(str(tab.get("key", "")).split(":", 1)[0])
]
if kept:
chat.canvas_json = {**tabs, "tabs": kept}
db.commit()
draft_service.forget(draft_id)
def _new_chat( def _new_chat(
db: DBSession, db: DBSession,
user: User, user: User,
@@ -202,6 +250,7 @@ async def start_chat(
project_dir: str = Form(""), project_dir: str = Form(""),
agent_mode: str = Form(""), agent_mode: str = Form(""),
reasoning_effort: str = Form(""), reasoning_effort: str = Form(""),
draft_id: str = Form(""),
) -> Response: ) -> Response:
"""Create a chat from its first message. """Create a chat from its first message.
@@ -227,6 +276,8 @@ async def start_chat(
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
) )
_adopt_draft(db, user, draft_id, chat)
user_message = chat_service.create_message(db, chat, ROLE_USER, content) user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if file_ids: if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id) files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
+17 -2
View File
@@ -173,6 +173,13 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
current = db.get(SshProfile, chat.ssh_profile_id) current = db.get(SshProfile, chat.ssh_profile_id)
if current is not None and current.owner_id != user.id: if current is not None and current.owner_id != user.id:
current = None current = None
elif chat is None and profiles:
# The new-chat screen. Which connection is *chosen* is a decision being
# made in the browser, so the server cannot know it -- what it can say is
# that there is one to choose, which is all the panels need in order to
# exist. They are pointed at a target by `lembas:agent-target`, and show
# nothing until they are.
current = profiles[0]
return { return {
"agent_profiles": profiles, "agent_profiles": profiles,
@@ -194,7 +201,13 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
# total gate would remove a working feature because one source is # total gate would remove a working feature because one source is
# unavailable. Absent on the new-chat screen for the reason the scope # unavailable. Absent on the new-chat screen for the reason the scope
# menu is: there is no row yet to hang a tab on. # menu is: there is no row yet to hang a tab on.
"canvas_enabled": chat is not None, # Also before the chat exists, where it opens on the connection being
# chosen in the composer. That reverses an earlier decision -- "there is
# no row yet to hang a tab on" -- which was true of the *storage* and
# was never a reason to withhold the panel: a draft holds its tabs in
# memory and hands them over when the chat is created. See
# services/agent/draft.py.
"canvas_enabled": chat is not None or bool(profiles),
# And whether it may *also* reach project files. Re-derived server-side # And whether it may *also* reach project files. Re-derived server-side
# on every canvas request; this flag only decides what the panel offers. # on every canvas request; this flag only decides what the panel offers.
"canvas_agent": canvas_service.agent_ready(db, user, chat) is not None, "canvas_agent": canvas_service.agent_ready(db, user, chat) is not None,
@@ -234,7 +247,9 @@ def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) ->
from lembas.db.models import KIND_AGENT from lembas.db.models import KIND_AGENT
from lembas.services.agent import ssh as ssh_service from lembas.services.agent import ssh as ssh_service
if chat is None or chat.kind != KIND_AGENT or profile is None: # `chat is None` is the new-chat screen, which may open a shell on the
# connection being chosen there. Everything else still has to hold.
if profile is None or (chat is not None and chat.kind != KIND_AGENT):
return False return False
if not permissions.has(db, user, "agent.terminal"): if not permissions.has(db, user, "agent.terminal"):
return False return False
+18 -4
View File
@@ -35,6 +35,7 @@ from lembas.db.session import session_scope
from lembas.security import permissions from lembas.security import permissions
from lembas.security.sessions import COOKIE_NAME, resolve_session from lembas.security.sessions import COOKIE_NAME, resolve_session
from lembas.services import settings_store from lembas.services import settings_store
from lembas.services.agent import draft as draft_service
from lembas.services.agent import session as agent_session from lembas.services.agent import session as agent_session
from lembas.services.agent import terminal as terminal_service from lembas.services.agent import terminal as terminal_service
from lembas.services.agent.base import ExecError from lembas.services.agent.base import ExecError
@@ -75,6 +76,20 @@ def _same_origin(websocket: WebSocket) -> bool:
return urlsplit(origin).netloc.lower() == host.lower() return urlsplit(origin).netloc.lower() == host.lower()
def _chat_or_draft(db, user, chat_id: str):
"""The chat this panel belongs to, real or still being decided.
A draft resolves to a transient `Chat` -- see services/agent/draft.py --
which is what lets the terminal open on the new-chat screen without
`_prepare` or `agent_session.resolve` learning that drafts exist.
"""
if draft_service.is_draft(chat_id):
draft = draft_service.get(chat_id, user.id)
return draft_service.as_chat(draft) if draft is not None else None
chat = db.get(Chat, chat_id)
return chat if chat is not None and chat.user_id == user.id else None
def _prepare(db, user, chat_id: str) -> tuple[str, dict]: def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
"""Everything that has to be true, and what opening needs. One or the other. """Everything that has to be true, and what opening needs. One or the other.
@@ -85,8 +100,8 @@ def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
if not permissions.has(db, user, "agent.terminal"): if not permissions.has(db, user, "agent.terminal"):
return "You do not have permission to open a terminal.", {} return "You do not have permission to open a terminal.", {}
chat = db.get(Chat, chat_id) chat = _chat_or_draft(db, user, chat_id)
if chat is None or chat.user_id != user.id: if chat is None:
return "That chat no longer exists.", {} return "That chat no longer exists.", {}
if chat.kind != KIND_AGENT: if chat.kind != KIND_AGENT:
return "This is an ordinary chat, so it has no machine to open a shell on.", {} return "This is an ordinary chat, so it has no machine to open a shell on.", {}
@@ -205,8 +220,7 @@ async def last_command(db: Db, user: RequiredUser, chat_id: str) -> dict:
buffer could not produce it anyway: it holds what is on screen, hard-wrapped buffer could not produce it anyway: it holds what is on screen, hard-wrapped
at the terminal's width, with no way to tell a wrap from a newline. at the terminal's width, with no way to tell a wrap from a newline.
""" """
chat = db.get(Chat, chat_id) if _chat_or_draft(db, user, chat_id) is None:
if chat is None or chat.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.") raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
if not permissions.has(db, user, "agent.terminal"): if not permissions.has(db, user, "agent.terminal"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.") raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.")
+183
View File
@@ -0,0 +1,183 @@
"""A chat that does not exist yet, so its panels can.
Chats are created lazily -- there is no endpoint that makes an empty one, and
the row appears together with its first message. That is a rule worth keeping:
an opened-and-abandoned composer should leave nothing behind. But it also meant
the terminal and the canvas were unavailable on the one screen where you are
deciding *which machine to work on*, which is exactly when you want to look
around it first.
A draft is the smallest thing that fixes that: an id, and the three facts the
panels need behind it. It is not a chat and never becomes one -- when the first
prompt is sent, a real chat is created and the draft's shell and tabs are
**adopted** into it, which is a re-key and a copy rather than a promotion.
The id is derived from (owner, connection, directory) rather than invented, so
that returning to the same new-chat screen finds the same shell and the same
tabs instead of quietly starting a second one. It is a hash so that neither the
directory nor the owner is legible in a URL.
"""
from __future__ import annotations
import hashlib
import time
from dataclasses import dataclass, field
from typing import Any
# How long a draft survives without being touched. Generous, because it is
# holding somebody's open files while they decide what to do; bounded, because
# nothing else will ever clean it up -- an abandoned new-chat screen leaves no
# row to cascade from and no chat to delete.
IDLE_TIMEOUT = 3600.0
# The prefix a draft id carries. It has to be distinguishable from a chat id at
# a glance and by code: `Chat.id` is 32 hex characters from `new_id`, so
# nothing here can collide with one by accident.
PREFIX = "draft_"
@dataclass
class Draft:
"""What a draft knows, which is only what the panels ask for."""
id: str
owner_id: str
profile_id: str
project_dir: str
# The canvas's tab strip, in the shape `Chat.canvas_json` holds. In memory
# rather than on a row for the obvious reason, and carried onto the chat at
# adoption.
canvas_json: dict = field(default_factory=dict)
touched_at: float = field(default_factory=time.monotonic)
_DRAFTS: dict[str, Draft] = {}
def is_draft(chat_id: str) -> bool:
return bool(chat_id) and chat_id.startswith(PREFIX)
def key_for(owner_id: str, profile_id: str, project_dir: str) -> str:
"""The id for one (owner, connection, directory), stably.
Derived rather than random so that reopening the new-chat screen on the same
target finds the shell that is already running there. The owner is in the
hash so that two people pointed at the same directory of the same connection
do not share a draft -- they would share a *shell*, and the terminal's own
"one chat, one shell" rule is scoped to a person's chats.
"""
material = "\0".join((owner_id, profile_id, project_dir or ""))
digest = hashlib.sha256(material.encode("utf-8")).hexdigest()
return f"{PREFIX}{digest[:24]}"
def remember(owner_id: str, profile_id: str, project_dir: str) -> Draft:
"""The draft for this target, created if this is the first time."""
_sweep()
key = key_for(owner_id, profile_id, project_dir)
draft = _DRAFTS.get(key)
if draft is None:
draft = Draft(
id=key, owner_id=owner_id, profile_id=profile_id, project_dir=project_dir or ""
)
_DRAFTS[key] = draft
draft.touched_at = time.monotonic()
return draft
def get(draft_id: str, owner_id: str) -> Draft | None:
"""One draft, if it is this person's.
The id is a hash of the owner, so a draft belonging to somebody else cannot
be guessed -- but it is checked rather than assumed, because "unguessable"
is not an authorisation and the next caller might build the id differently.
"""
draft = _DRAFTS.get(draft_id or "")
if draft is None or draft.owner_id != owner_id:
return None
draft.touched_at = time.monotonic()
return draft
def forget(draft_id: str) -> None:
_DRAFTS.pop(draft_id or "", None)
def clear() -> None:
_DRAFTS.clear()
def as_chat(draft: Draft) -> Any:
"""A `Chat` the panels can use, constructed and never saved.
This is the whole trick, and it is worth being precise about why it is safe.
`canvas.agent_ready`, `canvas._executor`, `_load_agent`/`_save_agent` and
`agent_session.resolve` read exactly four things off a chat -- `user_id`,
`kind`, `ssh_profile_id` and `project_dir` -- and none of them passes the
chat to a query or writes it back. So a transient row satisfies every one of
them unchanged, and no code that already works has to learn what a draft is.
`id` and `canvas_json` are set explicitly: both are *column* defaults, which
SQLAlchemy applies at flush, and this row is never flushed. An unset `id` is
not a cosmetic problem -- see `SOURCES_NEEDING_A_CHAT`.
"""
from lembas.db.models import KIND_AGENT, Chat
return Chat(
id=draft.id,
user_id=draft.owner_id,
kind=KIND_AGENT,
ssh_profile_id=draft.profile_id,
project_dir=draft.project_dir,
canvas_json=dict(draft.canvas_json or {}),
agent_mode="",
scope_json={},
)
# Canvas sources a draft may not open, refused by name.
#
# `scratch` needs a row: `scratch_service.for_chat` would write a `ScratchDoc`
# keyed on a chat that does not exist, which is the lazy-creation rule broken
# outright rather than bent.
#
# `file` is the one that matters. `canvas._load_file` authorises with
# `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
# stored with `chat_id=None`. If a draft's chat carried no id, `None != None` is
# False and every unclaimed attachment its owner has would open from any draft
# canvas. `as_chat` sets an id, so that comparison already fails -- but relying
# on it would mean the guarantee lives in an id-shaped coincidence. It is stated
# here instead, where it can be read and tested.
SOURCES_NEEDING_A_CHAT = frozenset({"scratch", "file"})
def refuses(source: str) -> bool:
return source in SOURCES_NEEDING_A_CHAT
def _sweep() -> None:
"""Drop drafts nobody has touched in a long while.
On write rather than on a timer: a draft holds no connection and no process,
only a little state, so there is nothing to close and nothing that leaks by
being late. The shell it points at has its own reaper.
"""
cutoff = time.monotonic() - IDLE_TIMEOUT
for key in [k for k, d in _DRAFTS.items() if d.touched_at < cutoff]:
_DRAFTS.pop(key, None)
__all__ = [
"SOURCES_NEEDING_A_CHAT",
"Draft",
"as_chat",
"clear",
"forget",
"get",
"is_draft",
"key_for",
"refuses",
"remember",
]
+24
View File
@@ -620,6 +620,30 @@ async def close_chat(chat_id: str, reason: str = CLOSED_REVOKED) -> bool:
return True return True
def rekey(old: str, new: str) -> Session | None:
"""Move a live session from one id to another, keeping the shell.
What adoption is made of: a shell opened on the new-chat screen under a
draft id becomes the shell of the chat that screen turned into, with its
scrollback and whatever is half-typed at its prompt. Nothing reconnects --
the browser navigates after `start_chat` and attaches to the session now
living under the real id, which is the "a reload is indistinguishable from a
second tab" property working for us rather than against us.
**Both the key and the field.** `close_for_profile`, `close_for_owner` and
the reaper all pop by `session.chat_id` rather than by the key they found it
under, so a stale field would leave a closed session in the registry that
`get` keeps handing out and `count_for` keeps counting.
"""
session = _SESSIONS.pop(old, None)
if session is None:
return None
session.chat_id = new
_SESSIONS[new] = session
log.info("terminal adopted %s -> %s", old, new)
return session
async def close_for_profile(profile_id: str) -> int: async def close_for_profile(profile_id: str) -> int:
"""End every shell opened on one connection. """End every shell opened on one connection.
+90
View File
@@ -0,0 +1,90 @@
/*
Pointing the terminal and the canvas at a chat that does not exist yet.
Both panels take their identity from the server: the terminal from
`data-url`, the canvas from URLs rendered inside the fragments it swaps in.
Neither builds a URL from parts. So all this has to do is get a *draft id* for
whatever the composer currently has selected, put it where the panels look,
and ask them to reload.
Only ever on the new-chat screen -- a chat that exists already has an id and
nothing here runs. See services/agent/draft.py for what a draft is and how it
is adopted when the first prompt turns it into a real chat.
*/
(function () {
"use strict";
var composer = document.querySelector(".composer");
if (!composer || composer.dataset.chatId) return;
var current = "";
/* The id travels with the first message so the server knows which draft to
adopt. A hidden field rather than a header, because it has to arrive as
part of the form that creates the chat. */
function field() {
var form = document.querySelector(".composer__form");
if (!form) return null;
var found = form.querySelector('input[name="draft_id"]');
if (!found) {
found = document.createElement("input");
found.type = "hidden";
found.name = "draft_id";
form.appendChild(found);
}
return found;
}
function point(id) {
if (id === current) return;
current = id;
var hidden = field();
if (hidden) hidden.value = id;
/* The terminal: one write, because every consumer re-reads `dataset.url`
per call rather than caching it. Reconnecting is the panel's own job --
it exposes `repoint` so the socket is closed and reopened by the code
that owns the closing flag. */
var terminal = document.querySelector("[data-terminal]");
if (terminal) {
terminal.dataset.url = id ? "/api/chats/" + id + "/terminal/ws" : "";
if (window.lembas && window.lembas.repointTerminal) {
window.lembas.repointTerminal();
}
}
/* The canvas builds no URLs in JavaScript at all -- they are all inside the
fragment. So re-pointing it is re-issuing the request that fetches that
fragment, and letting the server render the new id into everything. */
var inner = document.getElementById("canvas-inner");
if (inner && id && window.htmx) {
inner.setAttribute("hx-get", "/api/chats/" + id + "/canvas");
window.htmx.ajax("GET", "/api/chats/" + id + "/canvas", {
target: "#canvas-inner",
swap: "innerHTML"
});
}
var panel = document.querySelector("[data-canvas]");
if (panel) panel.dataset.chat = id;
}
document.addEventListener("lembas:agent-target", function (event) {
var target = event.detail || {};
if (!target.profileId) {
point("");
return;
}
/* One round trip per change of target, and the answer is stable: the id is
derived from (owner, connection, directory), so coming back to a target
finds the shell already running there rather than opening a second. */
fetch(
"/api/agents/" + encodeURIComponent(target.profileId) +
"/draft?dir=" + encodeURIComponent(target.projectDir || ""),
{ credentials: "same-origin" }
)
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (body) { if (body && body.id) point(body.id); })
.catch(function () { /* no panels rather than a broken one */ });
});
})();
+20
View File
@@ -463,6 +463,26 @@
document.addEventListener("lembas:theme", function () { document.addEventListener("lembas:theme", function () {
if (term) term.options.theme = readTheme(); if (term) term.options.theme = readTheme();
}); });
/* The panel now belongs to a different shell: the new-chat screen changed
connection or directory, so `dataset.url` has been rewritten and the
socket is pointed at the wrong machine. Closing is deliberate -- hence
the flag, which is what stops "Disconnected" being reported for something
nobody lost -- and the screen is cleared because this really is a
different shell, unlike a reconnect to the same one.
Reconnecting is left to the panel being opened, so a target changed while
the terminal is shut costs nothing. */
window.lembas = window.lembas || {};
window.lembas.repointTerminal = function () {
if (socket) {
closedOnPurpose = true;
socket.close();
socket = null;
}
if (term) term.reset();
if (panel && !panel.hidden && panel.dataset.url) connect();
};
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
+15 -1
View File
@@ -574,6 +574,7 @@ document.addEventListener("lembas:notify", function (event) {
if (dirLabel) dirLabel.textContent = value ? baseName(value) : "the login directory"; if (dirLabel) dirLabel.textContent = value ? baseName(value) : "the login directory";
var button = dirLabel && dirLabel.closest("[data-dir-browse]"); var button = dirLabel && dirLabel.closest("[data-dir-browse]");
if (button) button.title = value || "The connection's own login directory"; if (button) button.title = value || "The connection's own login directory";
announce();
} }
function sync() { function sync() {
@@ -588,13 +589,25 @@ document.addEventListener("lembas:notify", function (event) {
return (option && option.dataset.dir) || ""; return (option && option.dataset.dir) || "";
} }
/* Which machine and which directory the panels should be looking at.
Dispatched rather than read, because nothing here owns the terminal or
the canvas -- and because assigning to a hidden field's `.value` fires no
event of its own, so `setDir` has to say so out loud. */
function announce() {
var chosen = kind && kind.value === "agent" ? (picker ? picker.value : "") : "";
document.dispatchEvent(new CustomEvent("lembas:agent-target", {
detail: { profileId: chosen, projectDir: dir ? dir.value : "" }
}));
}
root.addEventListener("change", function (event) { root.addEventListener("change", function (event) {
if (event.target.name === "kind_choice") sync(); if (event.target.name === "kind_choice") { sync(); announce(); }
// Following the profile's own directory is a convenience, not a rule: // Following the profile's own directory is a convenience, not a rule:
// once someone has chosen their own it is left alone. // once someone has chosen their own it is left alone.
if (event.target === picker && dir && !dir.dataset.touched) { if (event.target === picker && dir && !dir.dataset.touched) {
setDir(profileDefault()); setDir(profileDefault());
} }
if (event.target === picker) announce();
}); });
root.addEventListener("click", function (event) { root.addEventListener("click", function (event) {
@@ -614,6 +627,7 @@ document.addEventListener("lembas:notify", function (event) {
so the two disagreed and the box offered a directory on a machine the so the two disagreed and the box offered a directory on a machine the
chat was not going to use. */ chat was not going to use. */
setDir(profileDefault()); setDir(profileDefault());
announce();
} }
/* The same directory picker, on a form that is not the composer. /* The same directory picker, on a form that is not the composer.
+5 -2
View File
@@ -16,7 +16,7 @@
#} #}
<aside class="canvas" id="canvas" hidden aria-label="Canvas" <aside class="canvas" id="canvas" hidden aria-label="Canvas"
data-canvas data-canvas
data-chat="{{ chat.id }}" data-chat="{{ chat.id if chat else "" }}"
data-resize-target> data-resize-target>
{# The left edge, dragged. A separator rather than a decoration: it takes {# The left edge, dragged. A separator rather than a decoration: it takes
focus and answers the arrow keys, or the panel is only resizable with a focus and answers the arrow keys, or the panel is only resizable with a
@@ -28,7 +28,10 @@
</div> </div>
<div class="canvas__inner" id="canvas-inner" <div class="canvas__inner" id="canvas-inner"
hx-get="/api/chats/{{ chat.id }}/canvas" {# Before a chat exists this fetches nothing until `draft.js` rewrites it,
which is what "intersect once" gives for free: the panel is hidden until
it is opened, so the trigger has not fired yet. #}
hx-get="{{ "/api/chats/" ~ chat.id ~ "/canvas" if chat else "" }}"
hx-trigger="intersect once" hx-trigger="intersect once"
hx-target="this" hx-swap="innerHTML"> hx-target="this" hx-swap="innerHTML">
<div class="panel-head"> <div class="panel-head">
+5 -2
View File
@@ -12,9 +12,12 @@
#} #}
<aside class="terminal" id="terminal" hidden aria-label="Terminal" <aside class="terminal" id="terminal" hidden aria-label="Terminal"
data-terminal data-terminal
data-url="/api/chats/{{ chat.id }}/terminal/ws" {# Empty before a chat exists: `draft.js` writes a draft id in as soon as
the composer has a connection selected. Explicit rather than relying on
Jinja rendering `None.id` as nothing. #}
data-url="{{ "/api/chats/" ~ chat.id ~ "/terminal/ws" if chat else "" }}"
data-label="{{ agent_profile.name if agent_profile else 'this connection' }}" data-label="{{ agent_profile.name if agent_profile else 'this connection' }}"
data-dir="{{ chat.project_dir }}" data-dir="{{ chat.project_dir if chat else "" }}"
data-resize-target> data-resize-target>
{# {#
The left edge, dragged. A separator rather than a decoration: it takes The left edge, dragged. A separator rather than a decoration: it takes
+6
View File
@@ -347,6 +347,12 @@
{# Unconditional: every chat has a transcript, and this is what keeps a block {# Unconditional: every chat has a transcript, and this is what keeps a block
somebody opened open across the swaps that arrive twelve times a second. #} somebody opened open across the swaps that arrive twelve times a second. #}
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script> <script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
{% if not chat and (canvas_enabled or terminal_enabled) %}
{# Only where there is no chat yet. It points both panels at a draft id for
whatever the composer has selected, and does nothing at all once a chat
exists -- which is every other page this block renders on. #}
<script src="{{ url_for('static', path='js/draft.js') }}" defer></script>
{% endif %}
{% if canvas_enabled %} {% if canvas_enabled %}
<script src="{{ url_for('static', path='js/canvas.js') }}" defer></script> <script src="{{ url_for('static', path='js/canvas.js') }}" defer></script>
{% endif %} {% endif %}
+360
View File
@@ -0,0 +1,360 @@
"""The terminal and the canvas before a chat exists.
Chats are created lazily and that rule is kept: a draft is not a chat and never
becomes one. What it does is hold an id, three facts and a tab strip, so the
panels can work on the screen where you are still deciding which machine to work
on -- and hand all of it over when the first prompt creates the real chat.
The security-shaped tests here are the two refusals. `canvas._load_file`
authorises with `attachment.chat_id != chat.id`, and a draft upload is stored
with `chat_id=None`; a draft whose chat carried no id would make that comparison
`None != None`, which is False.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import KIND_AGENT, Chat, Connection, Model, SshProfile, User
from lembas.services import settings_store
from lembas.services.agent import draft as draft_service
from lembas.services.agent import terminal as terminal_service
@pytest.fixture(autouse=True)
def _clean():
draft_service.clear()
yield
draft_service.clear()
@pytest.fixture
def target(db, registered):
settings_store.update(
db, {"enabled": True, "terminal_enabled": True}, key=settings_store.AGENTS
)
user = db.scalar(select(User))
profile = SshProfile(
owner_id=user.id,
name="Box",
host="127.0.0.1",
port=1,
username="nobody",
host_key="ssh-ed25519 AAAA",
host_fingerprint="SHA256:x",
default_dir="/srv/project",
)
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add_all([profile, connection])
db.commit()
db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True}))
db.commit()
return user, profile
# --- The id --------------------------------------------------------------------
def test_the_same_target_is_the_same_draft():
"""Derived rather than invented, so coming back to the new-chat screen finds
the shell already running there instead of quietly opening a second."""
one = draft_service.remember("u1", "p1", "/srv/x")
two = draft_service.remember("u1", "p1", "/srv/x")
assert one.id == two.id
assert draft_service.is_draft(one.id)
def test_a_different_directory_or_owner_is_a_different_draft():
base = draft_service.remember("u1", "p1", "/srv/x").id
assert draft_service.remember("u1", "p1", "/srv/y").id != base
assert draft_service.remember("u1", "p2", "/srv/x").id != base
# Two people pointed at the same directory of the same connection would
# otherwise share a *shell*, and one chat one shell is scoped to a person.
assert draft_service.remember("u2", "p1", "/srv/x").id != base
def test_a_draft_id_cannot_be_confused_with_a_chat_id():
"""`Chat.id` is 32 hex characters. Nothing here can collide with one."""
assert not draft_service.is_draft("a" * 32)
assert not draft_service.is_draft("")
def test_somebody_elses_draft_is_not_readable():
"""The id is a hash of the owner, so it cannot be guessed -- but that is not
an authorisation, and the next caller might build the id differently."""
made = draft_service.remember("u1", "p1", "/srv/x")
assert draft_service.get(made.id, "u1") is not None
assert draft_service.get(made.id, "u2") is None
# --- The transient chat --------------------------------------------------------
def test_the_transient_chat_carries_what_the_panels_read():
"""`agent_ready`, `_executor`, `_load_agent` and `agent_session.resolve` read
exactly these four, and none of them queries or writes the row -- which is
why none of them had to learn what a draft is."""
made = draft_service.remember("u1", "p1", "/srv/x")
chat = draft_service.as_chat(made)
assert chat.user_id == "u1"
assert chat.kind == KIND_AGENT
assert chat.ssh_profile_id == "p1"
assert chat.project_dir == "/srv/x"
assert chat.canvas_json == {}
def test_the_transient_chat_has_an_id_and_is_never_saved(db):
"""The id matters: `_load_file` compares it against an attachment's, and a
draft upload has `chat_id=None`. `None != None` is False."""
chat = draft_service.as_chat(draft_service.remember("u1", "p1", "/srv/x"))
assert chat.id, "a column default is applied at flush, and this is never flushed"
assert db.get(Chat, chat.id) is None, "and it must not have reached the database"
# --- The refusals --------------------------------------------------------------
def test_the_two_sources_that_need_a_row_are_refused_by_name():
"""Stated rather than left to fall out of an id comparison. `scratch` would
write a row keyed on a chat that does not exist; `file` is the hole."""
assert draft_service.refuses("scratch")
assert draft_service.refuses("file")
assert not draft_service.refuses("agent")
assert not draft_service.refuses("note")
def test_opening_a_refused_source_says_so_rather_than_failing(client, db, target):
user, profile = target
made = draft_service.remember(user.id, profile.id, "/srv/project")
response = client.post(
f"/api/chats/{made.id}/canvas/tabs", data={"key": f"scratch:{made.id}"}
)
assert response.status_code == 200
assert "once this chat exists" in response.text
assert db.query(Chat).count() == 0, "and no row was created on the way"
# --- Adoption ------------------------------------------------------------------
def test_sending_the_first_prompt_adopts_the_shell(client, db, target, monkeypatch):
"""The shell opened while deciding becomes the chat's shell, scrollback and
all. A re-key, not a reconnect: the browser navigates after `start_chat` and
attaches to the session now living under the real id."""
user, profile = target
made = draft_service.remember(user.id, profile.id, "/srv/project")
session = _fake_session(made.id, user.id, profile.id, "/srv/project")
terminal_service._SESSIONS[made.id] = session
try:
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": made.id,
},
)
chat = db.scalar(select(Chat))
assert terminal_service._SESSIONS.get(chat.id) is session
assert made.id not in terminal_service._SESSIONS
# Both, or `close_for_profile` and the reaper -- which pop by the field
# rather than by the key -- would leave a dead session findable.
assert session.chat_id == chat.id
finally:
terminal_service._SESSIONS.clear()
def test_a_shell_on_a_different_target_is_not_transplanted(client, db, target):
"""`_new_chat` settles the directory last: an empty one falls back to the
connection's own. A shell opened elsewhere belongs to the draft it was
opened under, and is reaped on idle rather than moved."""
user, profile = target
made = draft_service.remember(user.id, profile.id, "/somewhere/else")
session = _fake_session(made.id, user.id, profile.id, "/somewhere/else")
terminal_service._SESSIONS[made.id] = session
try:
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": made.id,
},
)
chat = db.scalar(select(Chat))
assert chat.id not in terminal_service._SESSIONS
assert session.chat_id == made.id
finally:
terminal_service._SESSIONS.clear()
def test_open_tabs_are_carried_onto_the_chat(client, db, target):
user, profile = target
made = draft_service.remember(user.id, profile.id, "/srv/project")
made.canvas_json = {
"tabs": [
{"key": "agent:/srv/project/main.py", "title": "main.py", "source": "agent"},
# Dropped rather than carried across to fail on first click.
{"key": "scratch:whatever", "title": "Scratch", "source": "scratch"},
],
"active": "agent:/srv/project/main.py",
}
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": made.id,
},
)
chat = db.scalar(select(Chat))
assert [t["key"] for t in chat.canvas_json["tabs"]] == ["agent:/srv/project/main.py"]
assert draft_service.get(made.id, user.id) is None, "and the draft is done with"
def test_a_draft_belonging_to_somebody_else_adopts_nothing(client, db, target):
user, profile = target
theirs = draft_service.remember("someone-else", profile.id, "/srv/project")
theirs.canvas_json = {"tabs": [{"key": "agent:/etc/shadow", "source": "agent"}]}
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": theirs.id,
},
)
chat = db.scalar(select(Chat))
assert not (chat.canvas_json or {}).get("tabs")
# --- The screen ----------------------------------------------------------------
def test_the_new_chat_screen_offers_both_panels(client: TestClient, target):
"""The decision this reverses: they used to require a chat, which meant they
were absent on the one screen where you are choosing a machine."""
html = client.get("/chat").text
assert "data-terminal" in html
assert "data-canvas" in html
assert "js/draft.js" in html
def test_the_draft_route_answers_with_a_stable_id(client: TestClient, target):
_user, profile = target
first = client.get(f"/api/agents/{profile.id}/draft?dir=/srv/project").json()
again = client.get(f"/api/agents/{profile.id}/draft?dir=/srv/project").json()
assert first["id"] == again["id"]
assert draft_service.is_draft(first["id"])
assert first["dir"] == "/srv/project"
def test_the_draft_route_refuses_somebody_elses_connection(client: TestClient, db, target):
_user, profile = target
stranger = User(email="s@x.test", name="S", password_hash="x")
db.add(stranger)
db.commit()
profile.owner_id = stranger.id
db.commit()
assert client.get(f"/api/agents/{profile.id}/draft").status_code == 404
class _FakeSession:
def __init__(self, chat_id, owner_id, profile_id, project_dir):
self.chat_id = chat_id
self.owner_id = owner_id
self.profile_id = profile_id
self.project_dir = project_dir
self.closed = False
def _fake_session(chat_id, owner_id, profile_id, project_dir):
return _FakeSession(chat_id, owner_id, profile_id, project_dir)
# --- Against a real machine ----------------------------------------------------
asyncssh = pytest.importorskip("asyncssh")
async def test_a_draft_canvas_opens_and_saves_a_project_file(db, registered, tmp_path):
"""The point of the whole thing: look around the machine, and edit something
on it, before committing to a conversation about it.
Against a real sshd and through `canvas_service` directly rather than the
TestClient -- the client drives the app on another thread, and the server
here is bound to this test's loop. What is being claimed is that a
*transient* chat drives the same SFTP path a real one does, and that is what
this exercises.
"""
from lembas.services import canvas as canvas_service
from lembas.services.agent import ssh as ssh_service
from tests.test_canvas_ssh import _Server
project = tmp_path / "project"
project.mkdir()
(project / "main.py").write_text("print('before')\n")
server = await asyncssh.create_server(
_Server,
"127.0.0.1",
0,
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
# SFTP only: the canvas never goes through a shell -- a path is a path.
sftp_factory=True,
)
port = next(iter(server.sockets)).getsockname()[1]
line, fingerprint = await ssh_service.capture_host_key("127.0.0.1", port)
try:
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
user = db.scalar(select(User))
profile = SshProfile(
owner_id=user.id,
name="Box",
host="127.0.0.1",
port=port,
username="tester",
host_key=line,
host_fingerprint=fingerprint,
default_dir=str(project),
)
db.add(profile)
db.commit()
made = draft_service.remember(user.id, profile.id, str(project))
chat = draft_service.as_chat(made)
key = f"agent:{project}/main.py"
doc = await canvas_service.load(db, user, chat, key)
assert "before" in doc.text
await canvas_service.save(db, user, chat, key, "print('after')\n", doc.revision)
assert (project / "main.py").read_text() == "print('after')\n"
# And none of it reached the database.
assert db.query(Chat).count() == 0
finally:
server.close()
await server.wait_closed()