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:
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
|
|||||||
lembas secret-key # generate LEMBAS_SECRET_KEY
|
lembas secret-key # generate LEMBAS_SECRET_KEY
|
||||||
lembas create-admin # create or promote an admin
|
lembas create-admin # create or promote an admin
|
||||||
|
|
||||||
pytest # 1513 tests, ~92s
|
pytest # 1529 tests, ~93s
|
||||||
# PLAN.md tracks what is and is not built
|
# PLAN.md tracks what is and is not built
|
||||||
ruff check . # lint (line length 100)
|
ruff check . # lint (line length 100)
|
||||||
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
||||||
@@ -1211,6 +1211,49 @@ Plain forms opt in with `data-confirm`, lone submit buttons with
|
|||||||
plus the picker block in `ui.js`; the value lives in a hidden input so it still
|
plus the picker block in `ui.js`; the value lives in a hidden input so it still
|
||||||
behaves as a form field.
|
behaves as a form field.
|
||||||
|
|
||||||
|
**The panels work before the chat does, and a draft is how.** The terminal and
|
||||||
|
the canvas both needed a `Chat`, which meant they were unavailable on the one
|
||||||
|
screen where you are deciding *which machine to work on*. `services/agent/draft.py`
|
||||||
|
holds an id and the three facts behind it -- owner, connection, directory -- and
|
||||||
|
`as_chat` builds a **transient `Chat`**, constructed and never added to a
|
||||||
|
session. That is the whole trick: `canvas.agent_ready`, `_executor`,
|
||||||
|
`_load_agent`/`_save_agent` and `agent_session.resolve` read only `user_id`,
|
||||||
|
`kind`, `ssh_profile_id` and `project_dir`, and none of them queries or writes
|
||||||
|
the row, so every one of them works unchanged and none had 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.
|
||||||
|
|
||||||
|
The id is **derived** from (owner, connection, directory) rather than invented,
|
||||||
|
so returning to the same new-chat screen finds the shell already running there
|
||||||
|
instead of quietly opening a second. The owner is in the hash because two people
|
||||||
|
pointed at the same directory would otherwise share a *shell*.
|
||||||
|
|
||||||
|
**Two canvas sources are refused on a draft, by name.** `scratch` needs a row --
|
||||||
|
`scratch_service.for_chat` would write a `ScratchDoc` keyed on a chat that does
|
||||||
|
not exist. `file` is the one that matters: `_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
|
||||||
|
`None != None` -- False -- and open every unclaimed attachment its owner has.
|
||||||
|
`as_chat` does set an id, so the comparison already fails; the refusal is stated
|
||||||
|
anyway, because a guarantee that lives in an id-shaped coincidence is one the
|
||||||
|
next change breaks silently.
|
||||||
|
|
||||||
|
**Adoption is a re-key and a copy, and the redirect does most of it.**
|
||||||
|
`start_chat` already answers `204` + `HX-Redirect`, so the browser reloads and
|
||||||
|
both panels re-render with the real id -- the canvas adopts by construction,
|
||||||
|
since its URLs are server-rendered, and the terminal reconnects to the re-keyed
|
||||||
|
session and replays its scrollback. `terminal.rekey` moves **both the registry
|
||||||
|
key and `session.chat_id`**: `close_for_profile`, `close_for_owner` and the
|
||||||
|
reaper all pop by the field, so a stale one would leave a dead session that
|
||||||
|
`get` keeps handing out. The shell is adopted only when its profile and
|
||||||
|
directory match the chat **as finally resolved** -- `_new_chat` falls back to the
|
||||||
|
connection's login directory when the field is empty -- and otherwise left where
|
||||||
|
it is rather than transplanted onto a chat that says it runs somewhere else.
|
||||||
|
|
||||||
|
`lembas:agent-target` is what re-points them when the selection changes; `ui.js`
|
||||||
|
dispatches it from `setDir` and the connection select, because assigning to a
|
||||||
|
hidden field's `.value` fires nothing on its own.
|
||||||
|
|
||||||
**Chats are created lazily.** There is no endpoint that makes an empty chat.
|
**Chats are created lazily.** There is no endpoint that makes an empty chat.
|
||||||
"New chat" is a link to `/chat`, which renders a composer with no row behind
|
"New chat" is a link to `/chat`, which renders a composer with no row behind
|
||||||
it; `POST /api/chats/start` writes the chat together with its first message.
|
it; `POST /api/chats/start` writes the chat together with its first message.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ that would be expensive to revisit. Kept current as work lands; the detail of
|
|||||||
**Status:** usable daily. Streaming chat, attachments, reasoning, tool calling
|
**Status:** usable daily. Streaming chat, attachments, reasoning, tool calling
|
||||||
with web search, custom HTTP tools and MCP servers, agent chats that work on a
|
with web search, custom HTTP tools and MCP servers, agent chats that work on a
|
||||||
machine over SSH, a knowledge library, notes, memory and skills, speech in and
|
machine over SSH, a knowledge library, notes, memory and skills, speech in and
|
||||||
out, users and groups, model administration, installable as an app. 1513 tests,
|
out, users and groups, model administration, installable as an app. 1529 tests,
|
||||||
`ruff` clean.
|
`ruff` clean.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -131,6 +131,10 @@ be a different project, not a refactor.
|
|||||||
commands together. A deny pattern cannot either — so in Auto a compound
|
commands together. A deny pattern cannot either — so in Auto a compound
|
||||||
line runs, which is the trade for Auto not asking about `cd build && make`.
|
line runs, which is the trade for Auto not asking about `cd build && make`.
|
||||||
See CLAUDE.md; matching each segment would restore both and is not built
|
See CLAUDE.md; matching each segment would restore both and is not built
|
||||||
|
- [x] **The terminal and the canvas open before the chat exists** — on the
|
||||||
|
new-chat screen, against the connection and directory being chosen there,
|
||||||
|
and both re-point when that changes. The shell you opened and the files
|
||||||
|
you left open are adopted into the chat when you send the first prompt
|
||||||
- [x] **Background jobs are visible** — a chip in the composer row counting what
|
- [x] **Background jobs are visible** — a chip in the composer row counting what
|
||||||
is still running, and a panel with each job's command, state, log tail and
|
is still running, and a panel with each job's command, state, log tail and
|
||||||
a Stop button. Survives a restart, because the job does
|
a Stop button. Survives a restart, because the job does
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 */ });
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user